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

blockwatcher

blockwatcher runs as a long-lived service that operators reconfigure entirely through its REST API: adding networks, contract specs, or delivery targets never requires stopping the process. Under the hood, it turns incoming on-chain data into typed values using the contract specifications you register, then runs your own conditions against that data to decide what counts as a match. Because none of this logic is chain-specific, chain support comes from swappable modules rather than the core itself, for example Ethereum and other EVM chains (the EVM module family ships with blockwatcher; other chain families plug in as modules without touching the core). It hands off each match to whatever endpoint, script, or log file you’ve wired up.

How to read this wiki

I want to…Start with…
Understand what blockwatcher doesUnderstanding
Get it runningGetting started, Guides
Operate it in productionConcepts, Reference
Contribute or extend itInternals
Embed the engine in another processEmbedding

The whole system in one picture

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
click sources "concepts/selectors.html"
click decoder "concepts/chain-agnosticism.html"
click matcher "concepts/predicates.html"
click gate "concepts/gates.html"
click sinks "concepts/delivery.html"
click storage "concepts/resources.html"
click api "reference/http-api.html"
click metrics "reference/observability.html"
click engine "concepts/pipeline.html"

Local development

Build and serve the wiki:

cargo install mdbook mdbook-mermaid --locked
mdbook serve docs/wiki --open

Release notes

Consumer-visible changes are tracked in the root CHANGELOG.md.

Understanding blockwatcher

This part answers what blockwatcher is before any of it needs to run: the problem it exists to solve, the shape of the pipeline it builds for you, and the vocabulary the rest of the wiki assumes you already have. Read it first if you are deciding whether blockwatcher fits your problem, or if a later page uses a term you have not seen defined yet.

What is blockwatcher?

Smart contracts emit events and receive calls constantly: a transfer, an approval, a call to an administrative function, a change of ownership. A team that cares about a handful of these conditions is usually left with two bad options: poll a block explorer’s API on a timer and hope its rate limits and uptime cooperate, or write and operate a bespoke script against an RPC endpoint for every condition, one script per rule, each with its own crash handling and no shared guarantees. Neither approach scales past a few rules, and both leave a team owning infrastructure that has nothing to do with the business rule they actually care about.

blockwatcher is a long-running service built to take that watching-and-deciding work off a team’s hands, for any number of rules across any number of chains it has a module for.

What blockwatcher does

At the center of an blockwatcher deployment sit four kinds of resource, and configuring all four is what makes something happen. A network tells blockwatcher where to look: a chain and the RPC endpoint(s) to pull activity from. A contract spec tells it what a piece of on-chain data means (an ABI, compiled once into a schema the rest of the system reasons about from then on). A monitor ties a network to a spec, names which addresses and which events or functions to pay attention to via its selectors, and optionally narrows that further with a predicate, a condition over the decoded fields. An optional gate can require several such hits, or cap alerts, before anything is delivered. A sink says where a hit should go.

Wire all four together and the loop needs no further attention from you: blockwatcher keeps ingesting from the network and checking every new occurrence against every active monitor, dispatching whatever counts as a match; see How it works for that loop traced step by step. None of this needs a process restart to change: every one of the four resource kinds above is created, edited, and deleted through a REST API while blockwatcher keeps running, and an edit takes effect on the running pipeline within the same request.

What blockwatcher is not

blockwatcher doesn’t keep a queryable history. It has no store of past chain state you can run open-ended historical questions against: it reacts to activity as it arrives (or, for a one-off test run, over a bounded range you specify through the API), and what it retains about anything it has already processed is limited to its own operational bookkeeping: checkpoints, dead letters, counters.

It also isn’t a place to build dashboards or discover trends. There is no aggregation layer, no time-series rollup, no chart: a match either reaches a destination you control or ends up in blockwatcher’s dead-letter store; anything you do with the resulting stream happens downstream, in whatever receives it.

And it doesn’t participate in a chain. blockwatcher holds no key, has no consensus role, and issues no transactions of its own: it only reads, through whatever RPC endpoint a network names, and its own reliability is bounded by that endpoint’s.

The four promises

No silent gaps. A slow sink never causes blockwatcher to drop anything to keep up: the pipeline holds events in its internal queues under backpressure rather than discarding work, so a struggling delivery target slows the whole pipeline down instead of losing data out of it. Recovering from a crash follows the same rule: blockwatcher resumes exactly where its last persisted checkpoint says it left off, which can mean re-sending a match it had already delivered but never means skipping one it hadn’t reached yet. The one place this promise does not hold is the evm-mempool source: a pending transaction has no fixed chain position, only an arrival order, so a restart there can genuinely lose whatever was mid-flight. Full mechanics: Delivery guarantees.

Bad configuration never fires silently. The failure mode this closes is specific: a monitor that looks correctly wired, runs for months, and never once fires because one field name has a typo three levels deep in a predicate: nothing about a monitor like that announces its own mistake. blockwatcher forecloses it by parsing, type-checking, and compiling every predicate and selector against the schema its monitor actually exposes the moment it’s written, rejecting anything it can’t resolve and naming its best guess at the field you meant. Full mechanics: Predicates and the expression language.

No chain knowledge leaks into the core. Every chain family, however it encodes its own data, normalizes into the same canonical value model before anything past the decoder ever touches it (arbitrary-precision integers included, so a 256-bit token amount survives decode to delivery without losing precision). That boundary is enforced, not just assumed: the engine that runs pipelines, tracks checkpoints, and serves the API never imports a chain SDK, an HTTP client, or a storage driver, and a CI gate breaks the build the moment a core crate tries. Full mechanics: Chain-agnosticism.

Swap any stage without touching the rest. How blockwatcher reads a chain, decodes its data, evaluates conditions, gates hits, delivers matches, and persists state are six independent choices, each an interchangeable implementation behind a small port trait, selected in config by name. Only EVM chain modules ship with blockwatcher, but the boundary is what will let a new chain family, or a new delivery target, arrive as its own module without the engine changing to accommodate it. Full mechanics: Modules and trade-offs.

How it works in one picture

Every network you register runs its own pipeline: a chain-specific source feeding a chain-agnostic decode-and-match stage, fanning out to whichever sinks the matching monitors name. A separate engine wires those pipelines up from resources managed over the REST API, and owns the bounded queues and checkpoint bookkeeping that make delivery safe to resume after a restart.

flowchart LR
    subgraph pipeline["one pipeline per network"]
        direction LR
        rpc("RPC source"):::module
        mem("Mempool source"):::module
        dec("Decoder"):::module
        mat("Matcher"):::module
        gate("Gate"):::core
        sw("Sink worker<br/>throttle · aggregate"):::core
        wh("Webhook sink"):::module
        scr("Script sink"):::module
        lg("Log sink"):::module
        rpc --> dec
        mem --> dec
        dec --> mat
        mat --> gate
        gate --> sw
        sw --> wh
        sw --> scr
        sw --> lg
    end

    api("REST API"):::core -->|writes resources| eng
    sto("Storage port<br/>checkpoints · dead letters"):::module <--> eng
    eng("engine<br/>bounded channels · checkpoint tracker"):::core --> pipeline
    eng -. interest hints .-> rpc
    eng -. interest hints .-> mem

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

Boxes drawn with a solid outline are swappable modules: an operator picks evm-rpc or evm-mempool for the source, webhook, script, or log for each sink, and so on. The engine and the REST API, drawn with a dashed outline, are fixed: every pipeline is built the same way regardless of which modules fill its module slots.

One event’s journey

  1. A source pulls activity. The RPC source (module name evm-rpc) polls its configured endpoints for new blocks and the logs and transactions in them; the Mempool source (evm-mempool) instead subscribes to one node’s pending-transaction feed. Either way, what reaches the next stage is a raw, chain-native payload tagged with a cursor, the source’s own position marker.

  2. The decoder normalizes it. Decoder takes that raw payload and the compiled schema from the relevant contract spec, and turns it into blockwatcher’s canonical value model, the same shape of data regardless of which chain family produced it. Nothing past this point knows or cares that the source was EVM-specific.

  3. The matcher evaluates every monitor. For each of the network’s active monitors whose selectors apply to this occurrence, Matcher runs the monitor’s compiled predicate (if it has one) against the decoded fields. A monitor with no predicate accepts everything its selectors already narrowed down to.

  4. A gate may stay quiet. If the monitor names a gate, the engine offers this hit and the persisted journal to that module. Retain or Discard mints nothing and does not stall the checkpoint. Emit mints a match or digest and continues as today.

  5. A match is fanned out. Every monitor that accepted the occurrence (Emit / passthrough) produces one match with a deterministic id, and the engine routes it to a Sink worker for every sink that monitor’s actions name. A sink with no throttle or aggregate configured delivers that match immediately; a sink with one of those set instead holds it in a window, dropping or bundling matches per the policy, and eventually delivers a plain match or a digest of everything the window collected. Either way delivery is one attempt per sink, independently retried.

  6. The checkpoint waits for the whole event. The engine only advances the network’s checkpoint past this event once every match it produced (across every monitor and every sink) has either been delivered or exhausted its retries and become a dead letter. Quiet gate hits are already done (outstanding 0). An event that produced no matches at all completes immediately; an event with five dispatched deliveries waits for all five.

If the process crashes anywhere in that sequence, it resumes on restart from the last checkpoint that step 6 actually persisted. Anything from an event at or before that checkpoint is done: matches were delivered or dead-lettered before the checkpoint advanced. Anything after it is redone from scratch, meaning a match already delivered right before the crash can be delivered again, but nothing already accepted onto a queue is ever skipped. A duplicate is possible; a gap is not.

Glossary

Every term below is defined by how blockwatcher’s code actually behaves, not by a general blockchain-industry sense of the word. Terms are alphabetical; each links to the others it depends on.

At-least-once delivery

blockwatcher’s delivery guarantee for a source whose cursor is a real chain position: a network’s checkpoint advances past an event only once every sink event that event produced has either reached its sink or been recorded as a dead letter after exhausting retries, never while a delivery is still outstanding. Quiet gate hits are not deliveries. An emitted match after a gate is still at-least-once (replay can duplicate). Both Match and Retracted are at-least-once; a consumer must treat a redelivered duplicate as the same occurrence (idempotent on match_id). Because the checkpoint is what a restart resumes from, the only way delivery can go wrong across a crash is repeating work already sent, never dropping work that was never sent. evm-mempool is the one shipped source this does not cover: its cursor is a per-run arrival counter rather than a chain position, so a crash there can lose whatever was in flight, and consumers should deduplicate its matches on the transaction hash rather than the match id.

Backpressure

What happens instead of dropping data when one pipeline stage runs behind another. A pipeline’s stages (source, decode-and-match, per-sink delivery) are connected by fixed-size queues; a stage that produces faster than the next one consumes simply waits for room in that queue before it can hand off more work. A source therefore never sees “buffer full, discard”: it sees its own send call take longer, which is the entire backpressure mechanism.

Canonical value model

The one representation every decoder normalizes chain-native data into, and the only representation a matcher, gate, or sink ever has to understand. It covers null, boolean, arbitrary-precision signed and unsigned integers, byte strings, addresses, strings, arrays, and ordered maps. Arbitrary-precision integers are why a 256-bit token amount compares and serializes exactly rather than losing precision the way a JSON number would; each chain family’s decoder is responsible for encoding its own values into this model without two distinct native values ever colliding on the same canonical one.

Checkpoint

The resume point a network’s pipeline persists: a cursor plus whatever extra state the source that wrote it needs to verify a safe resume (a block hash for reorg linkage, for instance). It advances only past a fully finished prefix of events: one still-outstanding sink event anywhere in that prefix holds the whole checkpoint back, even if later events already finished. A positively detected invalidate is the one case that rewinds it, and only backward to the proven from cursor, only after retracts have finished; a rewind that cannot move the cursor backward (the invalidation’s cursor is not behind the stored checkpoint, no checkpoint is stored, or the post-drain read failed) is refused and counted as blockwatcher_rewinds_refused_total, leaving the stored checkpoint unmoved and an absent one absent rather than fabricated. A gate Retain/Discard does not leave outstanding work; only an emitted match/digest does. Held hits persist in gate_hits without holding the checkpoint. Every checkpoint also records which source module wrote it, and resuming it under a different module is refused outright, because a cursor’s two numbers mean nothing outside the module that produced them.

Contract spec

A resource pairing a chain identifier with that chain’s decode artifact (for the evm chain family, a Solidity ABI) as an opaque payload. Writing a spec triggers its chain’s decoder to compile that artifact once into chain-agnostic event and function schemas; every selector and predicate that references the spec afterward works from those compiled schemas, never from the raw payload again. A spec has no module field of its own: its chain field alone determines which decoder compiles it.

Cursor

A pair of numbers a source uses to mark its own position in its feed, meaningful only inside the module that wrote them. The evm-rpc source uses the first number as a block number and packs the second to order a block’s transactions ahead of its logs; evm-mempool uses the first number as a monotonic arrival count and leaves the second at zero. Core code only ever compares and orders cursors: it never interprets what the numbers mean.

Dead letter

The record blockwatcher keeps of a sink event that could not be delivered after its sink’s retry budget ran out. It carries the match’s id, which monitor and sink produced it, the cursor it traces back to, how many attempts were made, why the last one failed, and, for events produced after this field was added, the original SinkEvent payload, which lets a Match or a Digest be replayed through the API. Legacy rows stored as a bare Match object still load as SinkEvent::Match. A retraction payload cannot be replayed. Recording a dead letter, rather than discarding the event outright, is what lets the checkpoint move past work that genuinely could not be delivered without pretending nothing happened.

Decoder

The port that turns a source’s raw payload into the canonical value model, one implementation per chain family. Nothing about the port boundary limits a deployment to one chain family; evm is simply the only one in the shipped module catalog today. A decoder compiles a contract spec’s payload into reusable schemas once, at write time, and decodes every later occurrence against those schemas rather than re-parsing the original artifact each time.

Delivery journal

The bounded per-network log of Match ids that were delivered or dead-lettered, used to emit Retracted events after an invalidate. Depth is instance config journal_depth (default 1024, in cursor primary units). Rows older than that window are self-pruned on the next write; there is no operator prune API. A successful retract forgets its row; a dead-lettered retract keeps it so a later invalidate can re-offer. See Delivery guarantees § The delivery journal.

Gate

A port that decides, for one monitor, whether a decoded occurrence that already passed selector and predicate becomes a match (or digest), given an engine-owned journal of earlier hits. threshold and max_once key windows on block.timestamp. Omit the monitor’s gate field for passthrough. See Gates. Distinct from sink throttle / aggregate, which run after a match exists.

Invalidate

A typed source outcome, SourceOutcome::Invalidated { from }, meaning everything with cursor > from previously implied by this source is on a dead fork and must not be resumed past from. It is not a SourceError. from is a cursor the source can defend with evidence: a proven fork point, or, when every tracked ancestor is refuted, just below the oldest tracked height (the minimal rewind that evidence permits). A source that holds no such evidence does not return this outcome. Core never names a chain or a reorg; only the source module decides when to return this. The engine drains that network, prunes gate_hits with cursor > from (rows with cursor ≤ from stay), retracts journaled deliveries after from (to that network’s sinks, or after delete to the sinks recorded on its runtime tombstone), rewinds the checkpoint when from is behind it (leaving it unmoved otherwise), then restarts. See Delivery guarantees § Shallow vs deep invalidation.

Match

One occurrence where a monitor’s selectors picked up a decoded event and its predicate, if it has one, accepted it. A match carries a deterministic id derived from the network, the monitor, the decoded event, and an index among the matches that one event produced, so a consumer that receives the same match twice under at-least-once delivery can recognize the duplicate by id, except from evm-mempool, where the same pending transaction can carry two different ids across a restart because its cursor is not a stable position.

Matcher

The port that evaluates a compiled predicate against a decoded event and reports whether it counts as a match. The shipped implementation, expr, is selected once for the whole instance in blockwatcher.toml rather than per monitor, and does not become per-monitor because Gate exists. The port boundary means an alternative predicate engine is a possible module, not a hardcoded choice.

Mempool

The set of transactions a node has received but has not yet included in a mined block. blockwatcher’s evm-mempool source subscribes to one node’s pending-transaction feed and fetches each transaction’s full data as its hash arrives. Because nothing here has settled yet, a pending transaction may never be mined at all: the trade-off this source makes for seeing activity before evm-rpc ever could.

Module

Any of the six swappable behaviors blockwatcher selects by name plus a module-specific config object: source, decoder, matcher, gate, sink, and storage backend. Every module implements exactly one port trait, and a module name that was never registered into the running binary is refused at write or boot time with the list of names that were.

Monitor

A resource that ties everything else together: it names one network to watch, one or more selectors describing what to decode from it, an optional predicate to filter what those selectors decode, an optional gate that decides whether a predicate-true hit becomes a match at all, and the sink ids a resulting match should reach. A monitor is compiled and schema-checked the moment it is written, and edits to it hot-swap into its running pipeline without a process restart.

Network

A resource naming a chain and a source module plus that module’s own config (where blockwatcher looks and how it ingests from there). Exactly one pipeline runs per network, and changing which source module a network uses restarts only that one pipeline, resuming from its persisted checkpoint as long as the checkpoint’s recorded module still matches.

Port

An object-safe Rust trait defining one axis of swappable behavior. blockwatcher defines six: Source, Decoder, Matcher, Gate, Sink, and Storage. Core code depends only on these trait definitions, never on any concrete module’s implementation: the reason a chain family’s code never has to be linked into, or even known by, the engine that drives it.

Predicate

A boolean expression, written in blockwatcher’s own small expression language, evaluated over a decoded event’s fields to decide whether it produces a match. It supports field access by namespace (args.* for decoded event fields; chain-specific namespaces such as tx.* and block.*), comparisons, boolean logic, arithmetic, and chain-native literals like hex addresses and token-decimal numbers. A predicate is type-checked against its monitor’s selector schemas the moment it is written, and at evaluation time a field an occurrence simply doesn’t carry resolves to an explicit “unknown” value rather than raising an error.

Seed

The one-time load of resources (networks, specs, sinks, monitors) from a directory of JSON files into empty storage, run at first boot via a CLI flag. It only ever populates a storage backend that has nothing in it yet; once a backend holds any resource, seeding again is a no-op, and afterward resources are managed exclusively through the REST API.

Selector

Part of a monitor: which addresses, which contract spec, and which of that spec’s events and/or functions to decode. A monitor’s selector entries are OR’d, and each entry is self-contained, so which spec governs which address is never ambiguous. What a given selector can actually produce depends on the network’s source: an events selector has nothing to decode on evm-mempool, which only ever delivers pending transactions, never logs.

Sink

The port responsible for delivering one sink event somewhere: a webhook, an operator-run script, or a log line are the modules in the shipped module catalog. Retry behavior on a failed delivery (how many attempts, backoff timing, when to give up and record a dead letter) is owned entirely by the engine and applied identically regardless of which sink module is delivering; a sink module itself never retries on its own. Every sink event variant shares that path. A sink whose SinkDef sets throttle or aggregate holds matches in a window before delivering, rather than delivering each one as it arrives; see Delivery guarantees § Aggregation. A gate digest is the same Digest wire shape aggregation already defined.

Sink event

What a sink receives: Match (apply this occurrence), Retracted { match_id } (undo the occurrence that id named), or Digest { matches } (apply several matches an aggregate window bundled together; see Sink). A closed set, serialized on the wire as tagged JSON (type: match, type: retracted, or type: digest). All three are at-least-once. See Delivery guarantees § What a sink receives.

Source

The port that pulls raw activity into a pipeline, whether by polling an RPC endpoint for blocks and logs or by subscribing to a node’s pending-transaction feed. A source owns the meaning of its own cursor, must emit events in non-decreasing cursor order, and is the only module family configured on the network resource rather than on a resource of its own. Source::run returns a SourceOutcome (Ended or Invalidated { from }) or a SourceError; a positively detected deep invalidate is the outcome, not the error.

Storage

The port responsible for every resource ever written (versioned, with optimistic concurrency), each network’s checkpoint, its dead-letter queue, its delivery journal, and each monitor’s gate_hits / gate_meta. Unlike the other five ports, a deployment picks exactly one storage backend for the whole process rather than one per resource: memory (no persistence, gone on restart) or sqlite (durable, single-writer) are the two in the shipped module catalog.

Getting started

This part gets blockwatcher running on your machine: building or installing it, watching one real pipeline end to end, driving that same pipeline through a browser instead of the command line, or embedding the engine in another process. It is for a reader about to run blockwatcher for the first time, not yet for one auditing its internals.

Quickstart

Get one blockwatcher pipeline running end to end: watching a live testnet contract and printing every occurrence it matches as JSON. This page assumes you already have a rough sense of what a network, a monitor, and a sink are; see What is blockwatcher? first if not. This page is about getting bytes moving; Your first monitor explains each piece in more depth, and the Concepts pages explain why the pieces are shaped the way they are.

The steps below all feed one running pipeline:

flowchart LR
    seed["seed/<br/>network, spec, sink, monitor"] --> check["blockwatcher check ./seed"]
    check -->|"ok: N networks..."| run
    cfg["blockwatcher.toml"] --> run["blockwatcher --config ... --seed ..."]
    seed --> run
    run --> pipe["running pipeline"]
    pipe --> out["stdout: one match per line"]

Prerequisites

  • A Rust toolchain at or above the workspace minimum, rust-version = "1.85". Tagged releases publish a Linux x86_64 archive and GHCR images (see Installation and building); building from source is the path this walkthrough uses.
  • An RPC endpoint URL for an EVM testnet. The example below uses Sepolia; a free key from Infura, Alchemy, or dRPC works.

From a clone of the repository, build the release binary once:

cargo build --release -p blockwatcher

The binary lands at target/release/blockwatcher.

1. Write an instance config

blockwatcher has two separate configuration planes. Instance config (blockwatcher.toml) is read once at process boot and controls process wiring: the API listener, the storage backend, engine tunables. It is not where you say what to watch; that’s a separate plane, managed at runtime (see Configuration later in this wiki). A minimal instance config:

[api]
enabled = true
listen = "127.0.0.1:8080"

[[auth.tokens]]
label = "operator"
scope = "admin"
secret = "env:BLOCKWATCHER_API_TOKEN"

[storage]
module = "sqlite"
config = { path = "blockwatcher.db" }

Each token’s secret is a reference (env:BLOCKWATCHER_API_TOKEN), never a literal token: boot refuses, naming the variable, if it is unset. GET /health answers without a token; everything else requires one.

2. Write a seed directory

blockwatcher loads its resources (what to watch, decode, and deliver) from a directory of JSON files on first boot only. Once the store holds anything at all, seeding is skipped and the resources plane is managed exclusively through the REST API from then on. A minimal seed needs one of each resource kind, one file per resource, sorted into kind subdirectories that the loader recognizes by name:

seed/
├── networks/sepolia.json
├── specs/usdc-erc20.json
├── sinks/log-sink.json
└── monitors/usdc-sepolia-transfers.json

networks/sepolia.json: where to look and how to ingest. start_block is an absolute block number rather than head-relative, so a restart resumes from exactly this point instead of silently skipping whatever passed while the process was down:

{
  "id": "sepolia",
  "chain": "evm",
  "source": {
    "module": "evm-rpc",
    "config": {
      "start_block": 9000000,
      "endpoints": [
        { "name": "primary", "url_secret": "env:SEPOLIA_RPC_URL" }
      ]
    }
  }
}

Set start_block near the chain’s current head: a value from months ago means a long wait before the first match arrives.

specs/usdc-erc20.json: the decode artifact. For "chain": "evm" this is a Solidity ABI fragment list; the decoder compiles it once into a schema everything downstream works from:

{
  "id": "usdc-erc20",
  "chain": "evm",
  "payload": [
    {
      "type": "event",
      "name": "Transfer",
      "anonymous": false,
      "inputs": [
        { "name": "from", "type": "address", "indexed": true },
        { "name": "to", "type": "address", "indexed": true },
        { "name": "value", "type": "uint256", "indexed": false }
      ]
    }
  ]
}

sinks/log-sink.json: one JSON line per match to stdout, no configuration of its own:

{
  "id": "log-sink",
  "module": "log",
  "config": {},
  "retry": { "max_attempts": 1, "initial_backoff_ms": 100, "max_backoff_ms": 1000 }
}

monitors/usdc-sepolia-transfers.json: ties the others together: watch this address on sepolia, decode Transfer against usdc-erc20, send every match to log-sink:

{
  "id": "usdc-sepolia-transfers",
  "network": "sepolia",
  "selectors": [
    {
      "addresses": ["0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238"],
      "spec": "usdc-erc20",
      "events": ["Transfer"]
    }
  ],
  "predicate": "args.value > 0",
  "actions": ["log-sink"]
}

3. Validate offline, then run

export SEPOLIA_RPC_URL=https://sepolia.infura.io/v3/YOUR_KEY

./target/release/blockwatcher check ./seed

check takes only a directory (it never reads blockwatcher.toml), but it constructs every module the seed references, including resolving each url_secret, so SEPOLIA_RPC_URL has to be set (to a real http/https URL: the check inspects its scheme, it does not need the endpoint to be reachable). BLOCKWATCHER_API_TOKEN isn’t needed yet; nothing about check touches the instance config that reads it.

A passing check prints a one-line summary to stdout and exits 0:

ok: 1 networks, 1 specs, 1 sinks, 1 monitors

Now export the API token and run for real:

export BLOCKWATCHER_API_TOKEN=quickstart-token

./target/release/blockwatcher --config ./blockwatcher.toml --seed ./seed

--seed only loads resources into a store that has nothing in it yet; once it has run once, blockwatcher.db is authoritative and the seed directory is ignored on every later boot.

What success looks like

Startup lines and warnings go to stderr; stdout carries nothing but tagged sink-event JSON ("type": "match", "type": "retracted", or "type": "digest" for a sink configured with aggregate), one line per delivery: nothing else is ever mixed in, so a consumer piping stdout into a parser never has to filter anything out. Within a few seconds of a matching Transfer on Sepolia, a line like this appears:

{
  "type": "match",
  "id": "a3f8b21c9d...",
  "monitor": "usdc-sepolia-transfers",
  "network": "sepolia",
  "event": {
    "kind": "event",
    "name": "Transfer",
    "fields": {
      "map": {
        "args": {
          "map": {
            "from": { "address": "0xff30fb28e1794bb91d5bceb7d66b731d0c61af8e" },
            "to": { "address": "0x7a3f2b16924f0e5c8f6a1c3d9e0b5a2f8c4d7e1a" },
            "value": { "uint": "13000000" }
          }
        },
        "tx": {
          "map": {
            "hash": { "bytes": "0x5f71ab..." },
            "index": { "uint": "26" },
            "status": { "uint": "1" }
          }
        },
        "block": {
          "map": {
            "number": { "uint": "11424039" },
            "hash": { "bytes": "0x9ab2cd..." },
            "timestamp": { "uint": "1785929472" }
          }
        },
        "log": {
          "map": {
            "address": { "address": "0x1c7d4b196cb0c7b01d743fbc6116a902379c7238" },
            "index": { "uint": "54" }
          }
        }
      }
    },
    "cursor": { "primary": 11424039, "secondary": 54 }
  }
}

Every scalar is tagged with its own type (address, uint, bytes), which is why a 256-bit integer like value survives as an exact decimal string rather than a lossy JSON number. USDC has 6 decimals, so 13000000 is 13 USDC. The top-level id is deterministic: the same occurrence always derives the same id, which is what lets a consumer recognize a redelivered match after a restart as a duplicate rather than as new activity.

Next: Your first monitor walks through this same shape of deployment file by file, and shows how to narrow what it matches.

Installation and building

A vX.Y.Z tag publishes a Linux x86_64 archive to GitHub Releases and two images to GHCR (ghcr.io/thethirdorigin/blockwatcher and ghcr.io/thethirdorigin/blockwatcher-ui). Building from source, or building the Docker image locally, remain the paths for anything that archive does not cover. The tag-triggered process is in CONTRIBUTING.md.

GitHub Release archive

gh release download vX.Y.Z --pattern "blockwatcher-*.tar.gz"
tar -xzf blockwatcher-X.Y.Z-x86_64-unknown-linux-gnu.tar.gz
./blockwatcher --version

The matching images are ghcr.io/thethirdorigin/blockwatcher:X.Y.Z and ghcr.io/thethirdorigin/blockwatcher-ui:X.Y.Z (also tagged vX.Y.Z). The UI image tag is the release it shipped with, not ui/server’s own crate version. Building the same images locally is Running with Docker.

Building from source

The workspace’s minimum Rust version is 1.85. Build the whole workspace:

cargo build --workspace

or just the binary crate:

cargo build --release -p blockwatcher

The resulting binary is target/release/blockwatcher (or target/debug/blockwatcher without --release).

Every commit is expected to pass the same gates CI runs on Linux:

cargo fmt --all --check
cargo check --workspace --locked
cargo clippy --workspace --all-features --all-targets -- -D warnings
cargo test --workspace --all-features
./scripts/check-dep-graph.sh
./scripts/check-release-version.test.sh
./scripts/changelog-release-notes.test.sh

CI additionally checks that every pairwise combination of the blockwatcher binary’s feature flags still builds:

cargo hack check --workspace --feature-powerset --depth 2 --locked

Worth knowing before reaching for a custom feature combination in production: the combinations that ship are the ones that are actually tested.

Feature flags

The blockwatcher binary crate defines optional features, all on by default:

[features]
default = ["evm", "expr", "sinks"]
evm = ["blockwatcher-embed/evm"]
expr = ["blockwatcher-embed/expr"]
sinks = ["blockwatcher-embed/sinks"]

Each forwards onto the matching blockwatcher-embed feature, which is what actually links the family’s crate: a family that isn’t linked can never be selected by name in config, however the config is written. Storage (memory, sqlite) is not behind a feature: blockwatcher-storage is a plain, non-optional dependency, so both storage modules are always present.

FeatureOff removes
evmBoth EVM sources (evm-rpc, evm-mempool) and the evm decoder, the EVM module family that ships with blockwatcher (see Modules and trade-offs for how other chain families would plug in). A build without it has no source and no decoder registered at all, so it can watch nothing.
exprThe expr matcher, the predicate engine that ships with blockwatcher (see Modules and trade-offs for how other matcher engines would plug in). A network can still be configured, but the engine’s [engine].matcher has nothing to select: booting refuses, naming expr as unavailable.
sinksAll three sink modules (webhook, script, log). A monitor’s actions can name a sink id, but the sink resource itself can never construct, so nothing can ever be delivered anywhere.

Building a custom binary that carries only what an operator’s deployment needs (say, evm and sinks without expr, if a different matcher module were added later) uses Cargo’s usual --no-default-features --features combination:

cargo build --release -p blockwatcher --no-default-features --features evm,sinks

A binary built this way still needs some matcher configured in [engine].matcher, and boot refuses at startup if the named module isn’t one this build actually carries: the refusal names what is available, not just what was asked for.

The check command and its exit codes

check runs the same construction and compilation path a real boot would, without ever starting a pipeline:

flowchart LR
    dir["seed directory"] --> load["load every JSON file"]
    load --> construct["construct every module<br/>resolves env: secrets"]
    construct --> compile["type-check every<br/>selector and predicate"]
    compile -->|"ok"| pass["print ok: N counts<br/>exit 0"]
    compile -->|"refused"| fail["print reason to stderr<br/>exit 1"]
    construct -->|"refused"| fail

blockwatcher check <dir> validates a seed directory offline: it loads every JSON file the directory holds, then runs the exact same construction and compilation path a real boot would run (every module gets built, every selector and predicate gets type-checked against its spec’s schema) without starting a pipeline or opening a listener. It takes only the directory argument; it never reads an instance config file, so nothing in blockwatcher.toml (the API token, the storage backend) plays any part in whether a check passes.

Because check constructs every module, any secret a seed’s configs reference through env:NAME must already be present in check’s own environment: a sink whose config resolves a secret at construction fails the same way it would at boot.

A pass prints a summary to stdout and returns 0:

ok: 3 networks, 5 specs, 2 sinks, 8 monitors

A failure prints the refusal to stderr and returns 1, naming the file and the reason: a malformed resource, a duplicate id within one kind, a selector referencing a spec that isn’t in the seed, a url_secret pointing at an unset variable.

The full set of exit codes the binary can return, useful for a supervisor’s restart policy:

CodeMeaning
0A clean drain (or a stop signal that arrived before boot finished), or a check that passed.
1A configuration, seed, or boot failure, or a check that refused.
2Shutdown reached its drain deadline with at least one pipeline still running work, and aborted it.
64A command line this binary could not parse (unrecognized flag, missing value).

check itself only ever returns 0 or 1: codes 2 and 64 belong to blockwatcher’s normal run mode and to argument parsing, respectively, neither of which check goes through.

Docker

docker/Dockerfile.blockwatcher builds the binary in a rust:1.88-bookworm stage (cargo build --release -p blockwatcher, with the default feature set: the Dockerfile passes no --features flags) and copies only the resulting binary into a debian:bookworm-slim runtime stage alongside ca-certificates and curl (the latter for the container healthcheck). The entrypoint is the binary itself, so container arguments are blockwatcher’s own CLI arguments:

docker build -f docker/Dockerfile.blockwatcher -t blockwatcher .
docker run --rm -p 127.0.0.1:8080:8080 \
  -e BLOCKWATCHER_API_TOKEN=change-me -e SEPOLIA_RPC_URL=https://sepolia.infura.io/v3/YOUR_KEY \
  -v "$(pwd)/blockwatcher.toml:/etc/blockwatcher/blockwatcher.toml:ro" \
  blockwatcher --config /etc/blockwatcher/blockwatcher.toml

(SEPOLIA_RPC_URL is only needed if the instance’s seeded or API-created networks reference it via url_secret: the binary itself doesn’t know that name, only whichever resources you give it do.)

For a full local stack (blockwatcher plus the companion dashboard), docker compose from the repository root is the supported path; see The dashboard.

Your first monitor

Quickstart got a pipeline running from files typed by hand. This page walks the same ground more slowly, using the runnable example shipped in the repository at examples/source-rpc-monitor/, and ends by changing what the monitor matches and watching the behavior change. The Concepts pages explain why each resource is shaped the way it is; this one is about doing it once, end to end.

What’s in the example

Eight tracked files (.env isn’t one of them: you create it from .env.example in a moment, and it’s git-ignored so your key never gets committed):

examples/source-rpc-monitor/
├── .env.example
├── README.md
├── blockwatcher.toml
├── setup.sh
└── resources/
    ├── monitors/usdc-sepolia-transfers.json
    ├── networks/sepolia.json
    ├── sinks/log-sink.json
    └── specs/usdc-erc20.json

Two of these aren’t blockwatcher resources at all: blockwatcher.toml is the instance config covered in Quickstart, and setup.sh is a one-off helper this page runs below: neither is read by check or seeded into storage. The other four are exactly the seed directory’s four resource kinds, one file each. Below, they’re discussed in the order they reference each other (network, then spec, then sink, then the monitor that ties all three together) rather than the tree’s alphabetical order.

The four files wire together into one running pipeline like this:

flowchart LR
    net["network<br/>sepolia.json"] --> mon
    spec["spec<br/>usdc-erc20.json"] --> mon
    sink["sink<br/>log-sink.json"] --> mon
    mon["monitor<br/>usdc-sepolia-transfers.json"] --> pipe["running pipeline<br/>on sepolia"]
    net --> pipe

resources/networks/sepolia.json names the evm-rpc source and tunes it beyond the defaults: 12 confirmations before a block is trusted, a starting eth_getLogs window of 1000 blocks growing to 5000, and a 30-second head-probe interval. None of these are required fields (the quickstart network file left every one of them at its default and still worked), but they’re realistic values for a testnet feed, not just illustration.

resources/specs/usdc-erc20.json is a Solidity ABI fragment list covering both Transfer and Approval events. Only Transfer is selected by the monitor below, but nothing stops a spec from carrying more than one monitor ever asks it to decode: the fragments a monitor doesn’t reference are simply unused.

resources/sinks/log-sink.json selects the log sink with a max_attempts of 1: one delivery attempt, dead-letter on failure, no retries. That’s a reasonable choice for a sink with nothing to fail on other than a broken stdout pipe.

resources/monitors/usdc-sepolia-transfers.json is the monitor: it watches that one address on the sepolia network, decodes only Transfer against usdc-erc20, keeps every occurrence where args.value > 0 (which is to say, everything a Transfer event can carry: a zero-value transfer is legal but rare), and sends every match to log-sink.

Running it

From the repository root:

cd examples/source-rpc-monitor
cp .env.example .env

Edit .env and replace YOUR_API_KEY in SEPOLIA_RPC_URL with a real Sepolia endpoint (a free key from Infura, Alchemy, or dRPC works). The key never gets written into any resource file: the network resource only names the SEPOLIA_RPC_URL variable, and blockwatcher resolves it from its own environment at construction time.

./setup.sh
set -a; . ./.env; set +a

setup.sh reads the current chain head over eth_blockNumber and rewrites resources/networks/sepolia.json’s start_block to a value just behind it. start_block is deliberately absolute, with no default: a head-relative start would derive a different block on every restart and silently skip whatever passed in between, which a monitor must never do. The trade-off is that a fresh network needs a block number named for it, and naming one from months ago means a long catching_up wait, visible on the GET /status endpoint, before anything is delivered.

cargo run --bin blockwatcher -- check ./resources
cargo run --bin blockwatcher -- --config ./blockwatcher.toml --seed ./resources

check constructs every module the seed references (including the evm-rpc source, which resolves SEPOLIA_RPC_URL), so it only passes with the environment loaded. A pass prints:

ok: 1 networks, 1 specs, 1 sinks, 1 monitors

The run command boots the engine, seeds resources/ into the empty blockwatcher.db it just created, and starts the pipeline. Diagnostics (the boot line, warnings) go to stderr; stdout carries nothing but match JSON, one line per delivery. Within a few seconds, matching transfers start arriving:

{"id":"a3f8b21c9d...","monitor":"usdc-sepolia-transfers","network":"sepolia","event":{"kind":"event","name":"Transfer","fields":{"map":{"args":{"map":{"from":{"address":"0xff30fb28e1794bb91d5bceb7d66b731d0c61af8e"},"to":{"address":"0x7a3f2b16924f0e5c8f6a1c3d9e0b5a2f8c4d7e1a"},"value":{"uint":"20000000"}}},"tx":{"map":{"hash":{"bytes":"0x5f71ab..."},"index":{"uint":"26"},"status":{"uint":"1"}}},"block":{"map":{"number":{"uint":"11424039"},"hash":{"bytes":"0x9ab2cd..."},"timestamp":{"uint":"1785929472"}}},"log":{"map":{"address":{"address":"0x1c7d4b196cb0c7b01d743fbc6116a902379c7238"},"index":{"uint":"54"}}}}},"cursor":{"primary":11424039,"secondary":54}}}

Pipe through jq to read it comfortably, or reduce to one line per transfer:

cargo run --bin blockwatcher -- --config ./blockwatcher.toml --seed ./resources \
  | jq -r '.event.fields.map.args.map | "\(.from.address) → \(.to.address)  \(.value.uint)"'

Narrowing what it matches

The seeded monitor’s predicate, args.value > 0, accepts every nonzero transfer. Suppose you only care about large ones, say, anything over 1000 USDC (six decimals: 1_000e6). The monitor is already running and its store already holds it, so re-seeding won’t touch it: --seed only loads into a store that has nothing in it yet. The way to change a live monitor is the same way you’d change any resource (the REST API), and the change takes effect on the running pipeline immediately, no restart.

Get the monitor’s current version (ETag), then PUT it back with the predicate changed:

export TOKEN=$BLOCKWATCHER_API_TOKEN   # from .env

etag=$(curl -s -o /dev/null -w '%header{etag}' \
  localhost:8080/monitors/usdc-sepolia-transfers \
  -H "Authorization: Bearer $TOKEN")

curl -s -X PUT localhost:8080/monitors/usdc-sepolia-transfers \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -H "If-Match: $etag" \
  -d '{
    "id": "usdc-sepolia-transfers",
    "network": "sepolia",
    "selectors": [{
      "addresses": ["0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238"],
      "spec": "usdc-erc20",
      "events": ["Transfer"]
    }],
    "predicate": "args.value > 1_000e6",
    "actions": ["log-sink"]
  }'

If-Match carries the resource’s current version so the API can refuse a write that would clobber a change made since you last read it; omitting it on a PUT to a resource that already exists returns 409 Conflict (already_exists), and a stale If-Match value returns 412 Precondition Failed (version_conflict). 1_000e6 is blockwatcher’s token-decimal literal syntax (one thousand at six decimals), evaluated against args.value as an arbitrary-precision integer, so the comparison is exact even though value itself arrived as a decimal string, not a JSON number.

The behavior change is immediate: transfers under 1000 USDC that used to print a line stop appearing entirely, while nothing about the pipeline restarts or re-scans: the predicate is recompiled and hot-swapped into the running monitor set, and only events decoded from here on are evaluated against the new one. Widen the predicate back, or remove it entirely (an absent predicate matches everything the selector decodes), the same way: another PUT with a fresh If-Match.

Next: The dashboard covers a web UI that does the same resource CRUD and log-watching this page just did by hand, and Concepts covers what the predicate language can express beyond a threshold. To require N transfers in a block-time window before alerting, see Gates.

The dashboard

Everything so far has driven blockwatcher by hand: editing JSON files, calling the REST API with curl. blockwatcher ships an optional local web UI that does the same operations (resource CRUD, watching matches arrive) through a browser instead.

What it shows

  • A monitors fleet table: every monitor, active or paused, with a live summary of its selectors and uptime.
  • Per-monitor stats: pause/resume, a dry-run against the live decoder and matcher (either raw test payloads or a bounded history scan), and stat cards for the monitor’s counters.
  • A virtualised match/event log per monitor, built to stay responsive even once a monitor has produced a large number of matches.
  • CRUD for every resource kind: networks, specs, sinks, and monitors, as either a structured form (for monitors, a selector editor with a schema helper) or raw JSON.
  • A networks page with per-network operational state: source status, lag, queue depth, cursor, pause/resume, skip-to-tip, and dead letters.

Two services, not one

The dashboard is not a feature you turn on inside the blockwatcher binary: it’s a second, independent process that sits in front of it. blockwatcher itself keeps running exactly as described everywhere else in this wiki: same REST API, same pipelines, same SQLite store, blockwatcher.db. What changes is that nothing outside its own container ever talks to it directly: its listen port isn’t published on the host at all. A second binary, blockwatcher-ui-server, built from Rust (Axum) and serving a compiled React single-page app, is the only thing that binds a host-reachable port (127.0.0.1:8080), and it holds its own SQLite database, ui.db, entirely separate from blockwatcher’s.

That server does two unrelated jobs behind the one port: it forwards every API call the browser makes through to the real blockwatcher API and relays the answer back, and it exposes an ingest endpoint that blockwatcher itself calls into to hand over matches. The first job is why a browser never needs to know blockwatcher’s address; the second is covered next.

flowchart LR
    browser["Browser"]

    subgraph ui_svc["ui service (127.0.0.1:8080, host-reachable)"]
        spa["React SPA"]
        srv["Companion server (Axum)"]
        uidb[("ui.db")]
    end

    subgraph ob_svc["blockwatcher service (internal only)"]
        api["REST API"]
        eng["Engine · pipelines"]
        obdb[("blockwatcher.db")]
    end

    browser --> spa
    spa --> srv
    srv -->|"proxy /api/blockwatcher/..."| api
    api --- eng
    eng -->|"webhook POST /ingest"| srv
    srv --> uidb
    eng --> obdb

Two consequences follow from that shape:

  • Every API call the browser makes is really two hops. GET /api/blockwatcher/networks from the browser reaches the companion server first, which forwards it to blockwatcher’s real GET /networks and relays the response back: blockwatcher is never addressable directly.
  • Matches reach the dashboard the same way any other consumer would: as a sink. The companion server registers a webhook sink named ui-ingest in blockwatcher automatically on boot. Any monitor whose actions include ui-ingest has its matches delivered (over HTTP, retried and dead-lettered by the engine exactly like any other webhook sink) to the companion’s /ingest endpoint, which stores them in ui.db for the log view. A monitor that omits ui-ingest from its actions runs and delivers to its other sinks as normal, but never shows up in the dashboard’s log.

Running it

From the repository root, copy and fill in the compose environment file:

cp docker/.env.example docker/.env

Edit docker/.env: set BLOCKWATCHER_API_TOKEN, UI_OPERATOR_SECRET, and UI_INGEST_SECRET (the last two must not be the same value). Set an RPC variable for each network you plan to create (SEPOLIA_RPC_URL if you follow the examples in this wiki). Every url_secret a network resource names must resolve to a variable present here, because blockwatcher resolves secrets inside its own container.

Then start the stack:

docker compose -f docker/compose.yaml up --build

The first run builds two images: one for blockwatcher itself, one for the companion server plus the built SPA. The ui container waits for blockwatcher’s health check (GET /health, no token) before serving. Open http://127.0.0.1:8080 and sign in with UI_OPERATOR_SECRET.

From there, create a network under Resources → networks, a spec if your selectors need ABI decoding, and a monitor with ui-ingest checked under Actions: matches appear in the monitor’s log within a few seconds of qualifying on-chain activity.

What the companion does and does not do

Operators sign in at /login with UI_OPERATOR_SECRET and hold an HttpOnly; SameSite=Strict session. The engine’s webhook authenticates with UI_INGEST_SECRET on x-blockwatcher-ingest; the two secrets must not be the same value. Compose still binds the dashboard to 127.0.0.1 because loopback is what keeps the login page off the wider network; re-point that bind to 0.0.0.0 only behind TLS (UI_COOKIE_SECURE=true) and a deployment story that is not “the operator’s laptop”. The engine API’s GET /health remains unauthenticated so Compose can probe it. The trust boundary is written out in the repository’s docs/threat-model.md.

Where the data actually lives

Two SQLite files back the two services, kept in named volumes rather than bind mounts so container recreation doesn’t lose them: blockwatcher’s own blockwatcher.db (the blockwatcher-data volume) holds resources, checkpoints, and dead letters, and pause state, exactly as it would outside Docker; the companion’s ui.db (the ui-data volume) holds only what it has ingested: match history. Pause state lives entirely in blockwatcher.db; the companion reads it from /status rather than keeping its own copy. Neither service reads the other’s file; the only thing that crosses between them is whatever the ui-ingest webhook forwards. docker compose down leaves both volumes in place for the next up; only down -v removes them, and that removal is permanent: every resource, checkpoint, and logged match is gone, with no confirmation beyond the flag itself. Because match bodies can carry on-chain data an operator considers sensitive, that risk applies to ui.db the same way it applies to any consumer that stores what a webhook sink delivers.

Embedding blockwatcher in a host process

The blockwatcher binary is one way to run the engine: it owns the CLI, instance config, HTTP control plane, metrics listener, and SIGTERM/SIGINT drain. A host that already has a process — a ledger-sync worker, a service that applies matches to its own store — can run the same engine in-process without standing any of that up.

That path is blockwatcher-embed. It is the one place compiled-in module families are registered (crates/blockwatcher-embed/src/catalog.rs), and it re-exports the engine types a host needs to boot and stop. The binary is a client of the same crate for catalog construction; it does not keep a second registrar.

flowchart LR
    subgraph host["host process"]
        embed["blockwatcher-embed<br/>build_catalog · Engine::start"]
        ports["host port impls<br/>Sink · Storage · Source<br/>Decoder · Matcher · Gate"]
        stop["host-owned shutdown"]
        embed --> ports
        stop --> embed
    end

    subgraph bin["blockwatcher binary"]
        embed2["blockwatcher-embed<br/>(same catalog + start)"]
        extra["CLI · config · seed<br/>API · metrics · SIGTERM"]
        extra --> embed2
    end

Key takeaways

  • Depend on blockwatcher-embed, not on the blockwatcher binary crate, to run the engine in-process.
  • build_catalog is the one registration path; Engine::start boots from a catalog, a storage backend, and an EngineConfig.
  • A host can supply an implementation of any of the six ports, not just Sink: register it on the catalog before Engine::start, or, for storage, hand it straight to EngineDeps.
  • A host sink implements Sink::deliver(&SinkEvent) in-process and must treat both Match and Retracted as at-least-once (idempotent on match_id).
  • Each port’s own obligations are the same in-process as in a shipped module; Extending blockwatcher § What each port asks of a module states them per port.
  • The host owns shutdown: call Engine::shutdown. Embed does not wire SIGTERM or SIGINT.
  • Embed does not serve the REST API or the Prometheus scrape endpoint. Those stay process concerns of the binary.

The host recipe

Add blockwatcher-embed to the host’s Cargo.toml. Default features (evm, expr, sinks) mirror the binary: they fold the same module families into the catalog. Storage (memory, sqlite) and gate modules (threshold, max_once) are always registered. A host-supplied gate uses the same compile/on_hit contract and still must not open sqlite. A host that implements its own Sink also depends on blockwatcher-ports and blockwatcher-types.

Boot is four steps: build the catalog, construct storage, start the engine, stop it yourself.

#![allow(unused)]
fn main() {
use blockwatcher_embed::{build_catalog, Engine, EngineConfig, EngineDeps};

let catalog = build_catalog()?;
let storage_factory = catalog.storage("memory")?;
let storage = storage_factory(serde_json::json!({}))
    .await
    .expect("memory storage constructs from empty config");

let config: EngineConfig = serde_json::from_value(serde_json::json!({
    "matcher": { "module": "expr", "config": {} }
}))?;

let engine = Engine::start(EngineDeps {
    storage,
    catalog,
    config,
})
.await?;

// Host-owned shutdown: embed does not install signal handlers.
let _report = engine.shutdown().await;
}

EngineConfig has no overall default because it refuses to guess a matcher module. Deserializing a JSON object that names expr (when that feature is on) fills every other tunable from core’s own defaults, including journal_depth = 1024. See Configuration reference § [engine].

Resources (networks, specs, sinks, monitors) still live in storage. A host either writes them through ControlHandle (re-exported from embed) or persists them into the backend before Engine::start, the same shapes the binary’s seed path writes. Embed does not load a seed directory of its own.

Any of the six ports can be the host’s own

A sink is the common case, not the only one. build_catalog returns a catalog the host still owns until EngineDeps takes it by value, and every register_* method on it is public, so a host can add its own module to any port family before start:

PortRegister withThen selected by
Sourcecatalog.register_source(name, factory)a network resource’s source.module
Decodercatalog.register_decoder(name, factory)nothing: automatic, by the ChainKind the decoder’s chain() returns
Matchercatalog.register_matcher(name, factory)EngineConfig.matcher.module
Gatecatalog.register_gate(name, factory)a monitor resource’s gate.module
Sinkcatalog.register_sink(name, factory)a sink resource’s module
StorageEngineDeps.storage, or catalog.register_storagehanded in directly; the name matters only to the binary’s [storage]

Two things do not change by being in-process. Each port’s contract is identical to a shipped module’s, so an in-process source still owes the engine non-decreasing cursor order and an in-process matcher still owes it a bounded compile; Extending blockwatcher § What each port asks of a module states those obligations per port, and they are worth reading before writing the impl rather than after. And a duplicate name within one family is EngineError::DuplicateModule from register_*, reported rather than panicked precisely so a host with no main of its own can recover.

A host that supplies its own module for a family usually also wants to stop linking the shipped one. blockwatcher-embed’s default features are evm, expr, and sinks; turning them off with default-features = false sheds those families, leaving storage (always registered) and whatever the host registers itself. What a build can select is then exactly what it linked, so a name the host never registered is refused at write or boot time listing only the names actually present.

An in-process sink

Shipped sink modules (webhook, script, log) still work: register a sink resource that names one of them, the same as in the binary. A host that wants to apply and undo by MatchId in the same process implements the Sink port itself and registers it on the catalog before start.

#![allow(unused)]
fn main() {
use async_trait::async_trait;
use blockwatcher_ports::{Sink, SinkError};
use blockwatcher_types::SinkEvent;

struct LedgerSink;

#[async_trait]
impl Sink for LedgerSink {
    async fn deliver(&self, event: &SinkEvent) -> Result<(), SinkError> {
        match event {
            SinkEvent::Match(m) => {
                // Apply by m.id. At-least-once: a duplicate id is the same
                // occurrence, not a new one.
                let _ = m;
                Ok(())
            }
            SinkEvent::Retracted { match_id } => {
                // Undo by match_id. Also at-least-once: must be idempotent.
                let _ = match_id;
                Ok(())
            }
        }
    }
}
}

(Sink is one method, crates/blockwatcher-ports/src/sink.rs. The engine owns retry, backoff, and dead-lettering; a sink that retries internally is a bug, the same contract as every shipped module.)

Retracted is how a consumer undoes work that a deep invalidate proved was on a dead fork. Register the impl on the catalog before start, under a name a sink resource can select:

#![allow(unused)]
fn main() {
let mut catalog = build_catalog()?;
catalog.register_sink("ledger", |_config| {
    Box::pin(async move { Ok(Arc::new(LedgerSink) as Arc<dyn Sink>) })
})?;
}

A stored sink resource with "module": "ledger" then constructs this type the same way a catalog sink does. Delivery guarantees covers when retracts fire, the bounded journal they read from, and the ordering that every retract for a sink finishes (delivered or dead-lettered) before any post-restart Match from that network is offered. A host that ignores Retracted will keep orphan rows it can no longer identify by id.

Classify failures through SinkError::Delivery { message, class: ErrorClass::… } exactly as a shipped sink does. The engine’s retry loop reads that class; it does not know this sink is in-process.

Host-owned storage

Storage is the one port a host does not have to register at all. EngineDeps.storage is an Arc<dyn Storage>, so a host that already has a database hands its own backend straight in, and every resource, checkpoint, dead letter, and journal row lands in the host’s store instead of a sqlite file beside the process:

#![allow(unused)]
fn main() {
use std::sync::Arc;
use blockwatcher_ports::Storage;

let storage: Arc<dyn Storage> = Arc::new(LedgerStore::new(pool));

let engine = Engine::start(EngineDeps {
    storage,
    catalog: build_catalog()?,
    config,
})
.await?;
}

catalog.register_storage exists for symmetry, but nothing in the engine resolves storage by name; only the binary does, from [storage] in blockwatcher.toml. A host registers a storage module only if its own configuration should be able to name one.

Storage is also the widest port and the only one with a shared behavioural contract. Take blockwatcher-testkit as a dev-dependency and run exercise_storage_contract against the host backend: it is the same sequence memory and sqlite pass, so passing it is what makes the host’s store equivalent to them rather than merely compiling (blockwatcher-testkit § The storage contract). The rules it checks, optimistic concurrency on every write, unpaginated resource listings, and pruning the journal inside record_delivery, are stated in blockwatcher-storage § The Storage port contract.

A host-owned source

A host that already streams a chain, or that reads from an internal bus a shipped source knows nothing about, implements Source and registers it under a name a network resource can select:

#![allow(unused)]
fn main() {
use blockwatcher_ports::{Source, SourceError};

catalog.register_source("ledger-feed", |config| {
    Box::pin(async move {
        let config: FeedConfig =
            serde_json::from_value(config).map_err(|e| SourceError::InvalidConfig {
                message: format!("invalid ledger-feed config: {e}"),
            })?;
        Ok(Arc::new(LedgerFeed::new(config)) as Arc<dyn Source>)
    })
})?;
}

A stored network resource with "source": { "module": "ledger-feed", … } then gets one instance per network, exactly as evm-rpc would. The obligations are unchanged by the source being local: events go out through ctx.events in non-decreasing cursor order from a single loop, ctx.cancel means return SourceOutcome::Ended, a detected rewind is SourceOutcome::Invalidated { from } rather than an error, and the cursor mapping is the host’s to choose and document. scan and confirmed_tip default to Unsupported, so a streaming-only feed implements neither.

blockwatcher-testkit’s recv and stop are worth pulling in as a dev-dependency here too: they drive the source under a deadline and fail a run that ignores its cancellation token instead of hanging the host’s test suite.

A host-owned decoder

A decoder is the one module a host registers but never names. The engine constructs every registered decoder at boot with an empty config object and indexes them by the ChainKind each chain() returns, so a host decoder becomes reachable purely by storing Spec resources whose chain matches:

#![allow(unused)]
fn main() {
use blockwatcher_ports::{Decoder, SpecError};

catalog.register_decoder("ledger-wire", |_config| {
    Box::pin(async move { Ok(Arc::new(LedgerDecoder) as Arc<dyn Decoder>) })
})?;
}

Two registered decoders claiming the same ChainKind is EngineError::DuplicateChainDecoder at boot, so a host adding a decoder for a chain the build already carries should drop that family’s feature first. The compile-time and hot-path obligations, resolving only the specs a selector names, matching declared field names to the shapes actually decoded, and keeping decode output order deterministic, are where a host decoder most often goes quietly wrong; Extending blockwatcher § Decoder states each with the failure it produces.

A host-owned matcher

A host with its own predicate language implements Matcher, registers it, and names it in EngineConfig instead of expr:

#![allow(unused)]
fn main() {
catalog.register_matcher("host-lang", |_config| {
    Box::pin(async move { Ok(Arc::new(HostMatcher) as Arc<dyn Matcher>) })
})?;

let config: EngineConfig = serde_json::from_value(serde_json::json!({
    "matcher": { "module": "host-lang", "config": {} }
}))?;
}

One matcher runs per process, which is why EngineConfig has no overall default and refuses to guess one. A host that replaces expr should build embed without the expr feature; DEFAULT_MATCHER exists only under that feature, and nothing else fills the field. The obligation most easily missed is that compile takes operator-controlled source: the engine bounds the length it passes in, but the module must bound the work and memory one call can consume, because a compile that can be driven to exhaust a stack or heap is a write-path denial of service.

What embed is not

  • Not a second engine API. Engine::start, Engine::shutdown, and ControlHandle are the same types the binary calls. Embed does not wrap them.
  • Not a signal handler. The host decides when to drain. Engine::shutdown is the same deadline-bounded drain the binary maps onto its exit codes; without a host call, nothing asks the engine to stop.
  • Not the HTTP or metrics stack. blockwatcher-api and blockwatcher-metrics are not dependencies of embed. A host that wants those listeners still runs the binary, or wires those crates itself.
  • Not a second registration path for workspace modules. A module that ships in the workspace still becomes selectable through one get_all() line in its own crate, which build_catalog folds, and the binary must not grow its own catalog beside embed’s. A host registering a type it owns is the separate case above: it adds to the catalog build_catalog returned rather than replacing how that catalog is built.

Extending blockwatcher walks a shipped module through registration step by step and states what each of the six ports asks of the module behind it, in § What each port asks of a module; its § Extending to a new network covers the chain-family case, which a host reaches by registering a Source and a Decoder together rather than by adding a crate to the workspace. blockwatcher-embed is the crate page: features, allowlist, and what it re-exports.

Concepts

This part opens up the model underneath the pipeline: what a resource is, how a predicate turns a decoded occurrence into a yes-or-no answer, what blockwatcher guarantees about delivery, and where the boundary sits that keeps the engine from ever needing to know which chain it is watching. It is for a reader who has already run blockwatcher and now wants to understand why it behaves the way it does.

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,sources,sinks,storage,api,metrics dim
class engine,decoder,matcher,gate 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

  • Concepts pages explain the model behind the pipeline, not how to run it: resources, predicates, gates, delivery, and the chain-agnostic boundary.

  • The pipeline page covers which task does what and where the bounded queues sit; the resources page covers the resource kinds blockwatcher watches and delivers to.

  • Predicates, gates, selectors, and delivery guarantees together describe how a decoded event becomes a match and reaches a sink.

  • Chain-agnosticism and modules describe the boundaries that keep the engine chain-unaware and every axis of behavior swappable.

  • The pipeline: which task does what, and where the bounded queues sit.

  • Resources: the resource kinds blockwatcher watches and delivers to.

  • Predicates and the expression language: how one decoded occurrence becomes a match.

  • Gates: when a predicate-true hit becomes a delivery, across other hits of the same monitor.

  • Selectors: how a monitor points at a contract spec.

  • Delivery guarantees: what “done” means, and what happens when delivery fails.

  • Chain-agnosticism: the boundary that keeps the engine chain-unaware.

  • Modules and trade-offs: the swappable pieces behind each port, and what each choice costs.

The pipeline

How it works in one picture sketched a single pipeline. This page opens it up: which task does what, which crate implements it, where the bounded queues sit, and (the part that only reading crates/blockwatcher-core/src/pipeline/ settles) exactly what is shared across a deployment and what is private to one network.

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,sources,sinks,storage,api,metrics,decoder,matcher,gate dim
class engine focus
linkStyle 6,7,8 stroke:#d9480f,stroke-width:3px
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

  • spawn turns one network’s PipelineSpec into four task kinds (source, processor, sink worker, checkpoint writer) linked end to end by channels.
  • The decoder is shared per chain and the matcher is one instance for the whole engine; only the source, monitor set, and sink workers are per-network.
  • The two bounded channels never drop a message: a full channel stalls the pipeline at the point of congestion rather than losing anything.
  • A raw event registers with the progress tracker exactly once, after dispatch, for however many matches it produced, even zero.
  • After a predicate-true decode, an optional per-monitor gate may Retain or Discard the hit with outstanding 0, or Emit a match/digest. Holding a hit does not stall the checkpoint.
  • A cursor only ever persists once every completion guard the event handed out, across every monitor and sink it touched, has completed.

One pipeline per network

Engine::start builds one PipelineSpec per stored network and spawns it once, and every later network write goes through the same spawn call by way of a restart (crates/blockwatcher-core/src/pipeline/mod.rs). A PipelineSpec bundles that network’s already-constructed source, its chain’s decoder, the engine’s one matcher instance, the network’s compiled monitor set, and every sink any of those monitors names (crates/blockwatcher-core/src/pipeline/mod.rs). spawn turns that bundle into the task kinds below, wired together by channels it owns end to end.

flowchart LR
    src["Source task<br/>blockwatcher-evm"] -->|"events channel<br/>(bounded)"| proc
    proc["Processor task<br/>decode + match + gate<br/>blockwatcher-core + blockwatcher-evm + blockwatcher-expr"]
    proc -->|"sink channel<br/>(bounded, per sink)"| sw["Sink worker task<br/>blockwatcher-core"]
    proc -.->|register cursor| prog["Progress tracker<br/>blockwatcher-core"]
    sw -.->|complete / dead-letter| prog
    prog -->|checkpoint| cpw["Checkpoint writer task<br/>blockwatcher-core"]
    sw -->|on exhaustion| dl[("dead letters<br/>blockwatcher-storage")]
    cpw --> store[("checkpoint<br/>blockwatcher-storage")]

    classDef task fill:none,stroke:#a9a3e3
    class src,proc,sw,cpw task

Each task kind, and the crate doing the real work inside it:

  • Source task: one per network, running whatever module the network’s source.module names (evm-rpc or evm-mempool, both in crates/blockwatcher-evm). It owns its own notion of position, its cursor, and must hand events to the next stage in non-decreasing cursor order, the contract Processor::run states for its receiver; nothing downstream reorders what it reads (crates/blockwatcher-core/src/pipeline/processor.rs).
  • Processor task: one per network, decoding, matching, and gating in the same loop. It calls the network chain’s Decoder (the evm decoder in crates/blockwatcher-evm, the only one shipped) once per active, unpaused monitor’s selector, then evaluates that monitor’s compiled predicate through the engine’s single Matcher instance (blockwatcher-expr’s expr matcher, unless a deployment swaps it); see Processor::process_one (crates/blockwatcher-core/src/pipeline/processor.rs). Gate arithmetic lives in pipeline/gate.rs, not inlined in processor.rs.
  • Sink worker task: one per sink a network’s monitors reference, running the sink module (webhook, script, or log) named by that sink’s SinkDef. It owns delivery retry and backoff and dead-lettering, identical regardless of which module is attached, per SinkWorker’s own doc comment (crates/blockwatcher-core/src/pipeline/sink_worker.rs).
  • Checkpoint writer task: one per network, persisting whatever the progress tracker publishes and refusing to persist anything older than what it already wrote this run, in CheckpointWriter’s persist method (crates/blockwatcher-core/src/pipeline/checkpoint_writer.rs).

The Progress tracker (crates/blockwatcher-core/src/progress.rs) is not a task; it is a shared, lock-protected structure the processor registers events into and sink workers complete guards against, read by the checkpoint writer. It is covered in full on Delivery guarantees; this page treats it as the thing that turns “every dispatched match done” into “safe to persist this cursor.”

What is shared, and what is per-network

The pipeline diagram makes every task look network-scoped, but two of the module families it touches — the decoder and the matcher — are actually shared engine-wide, constructed once at boot and reused rather than rebuilt per network or per pipeline restart:

ComponentScopeWhere it’s built
Source instance & taskOne per network, from that network’s source module selection.build_pipeline_spec, crates/blockwatcher-core/src/engine/control.rs (constructed fresh on every restart)
Decoder instanceOne per chain, shared by every network on that chain.validate_and_build, crates/blockwatcher-core/src/engine/boot.rs (built once at boot, kept in Engine.decoders)
Matcher instanceOne for the whole engine: the module named in blockwatcher.toml’s matcher field, never per network or per monitor.validate_and_build, crates/blockwatcher-core/src/engine/boot.rs (built once at boot, kept in Engine.matcher)
Monitor setOne MonitorSet per network, holding every monitor whose network field names it.build_pipeline_spec, crates/blockwatcher-core/src/engine/control.rs
Sink module instanceConstructed fresh from the stored SinkDef every time a network’s pipeline is (re)built, not retained across a restart.build_pipeline_spec, crates/blockwatcher-core/src/engine/control.rs
Sink worker task, its channel, and its retry loopOne per (network, sink) pair: a sink two networks both reference gets two independent workers, two independent channels, two independent retry loops.spawn, crates/blockwatcher-core/src/pipeline/mod.rs
Events channel (source → processor)One per network.spawn, crates/blockwatcher-core/src/pipeline/mod.rs
CheckpointOne per network, keyed by NetworkId.CheckpointWriter’s network field, crates/blockwatcher-core/src/pipeline/checkpoint_writer.rs

The two easiest to get wrong: the decoder is per chain, not per network (two evm networks decode through the exact same Arc<dyn Decoder>), and the matcher is a single engine-wide instance, not something a monitor or a network picks; blockwatcher.toml names it once for the entire process, as Engine’s matcher field doc comment states (crates/blockwatcher-core/src/engine.rs). A sink’s module instance, by contrast, is rebuilt every time a pipeline is (re)constructed: it is not shared even across two restarts of the same network, though this is invisible in practice because a sink module carries no state beyond its config.

Backpressure: two bounded channels, no drops

Between source and processor sits one tokio::sync::mpsc channel of capacity event_channel_capacity (default 256); between processor and each sink worker sits one of capacity sink_channel_capacity (default 64), both configured once in blockwatcher.toml’s engine section and applied to every pipeline alike, EngineConfig’s fields, read by spawn (crates/blockwatcher-core/src/config.rs, crates/blockwatcher-core/src/pipeline/mod.rs). Neither channel ever drops a message when full. A source’s send and the processor’s dispatch are plain awaited .send() calls, in Processor::process_one (crates/blockwatcher-core/src/pipeline/processor.rs); when a channel is at capacity, that await simply doesn’t resolve until a slot opens, which means a slow sink worker’s channel filling up stalls the processor’s dispatch loop, which in turn stalls the source’s own send: the whole pipeline pauses at the first point of congestion rather than losing anything. The two channels close only when their sending side is dropped, which is what a graceful shutdown or restart uses to drain: cancel the source, its sender drops, the processor’s receive loop ends and drops the sink senders, and each sink worker drains whatever is already queued before it exits, the cascade spawn’s own doc comment states (crates/blockwatcher-core/src/pipeline/mod.rs).

Escalation, abort, and the storage fence

Every drain (a whole-engine shutdown, and a single network’s stop or restart through the control plane) is bounded by drain_deadline_ms. Missing that deadline escalates: a hard_cancel signal fires, tasks that are still running get a brief grace window to exit on their own, and anything still not done after that is stopped with a forced abort() (crates/blockwatcher-core/src/engine/drain.rs). An escalation like this is always loud (a warning naming the network) and always counted (blockwatcher_pipelines_aborted_total).

An abort() can leave more behind than a stopped task: a storage backend whose operations run independent of the caller that started them (sqlite’s own connection lives behind a tokio::task::spawn_blocking call, for example) keeps running whatever it was asked to do at the moment of the abort, and that call still lands later, on its own schedule. Without accounting for it, a pipeline restarted right after such an escalation could read its resume point, or its gate journal, before that residual write lands, then start delivering against what it read, only for the residual write to land afterward and overwrite what the restart’s own work just persisted: a gap, not a duplicate.

Storage::quiesce closes that window: after any escalated drain, the engine awaits it, bounded by its own budget, before treating the network as safely stopped. It resolves once every storage operation an aborted task had in flight has actually completed. One function, stop_pipeline, is where every single-network stop and restart drains through, so this quiescing, its budget, and its counting all live in that one place regardless of which control-plane path called it. Three paths read what it decides before touching storage themselves, and all three refuse in the same shape when it times out, leaving the network abandoned (visible in /status the same way a failed crash-restart already is) until an operator retries or the process restarts:

  • a restart, before re-reading the checkpoint and gate outbox it would otherwise redeliver or overwrite against;
  • a gate envelope change or a gated monitor’s delete, before dropping that monitor’s held journal, which a residual write could otherwise resurrect after the drop; and
  • a reorg invalidation, before pruning gate holds, retracting journaled deliveries, or rewinding the checkpoint, any of which a residual write could otherwise race.

Every one of these refusals is counted under blockwatcher_pipelines_quiesce_timeout_total exactly once, at stop_pipeline itself, no matter which of the three paths called it or how many times. A timeout is not forgiven just because a later attempt on the same network finds no pipeline left running to escalate: the engine remembers the debt and re-attempts the same quiesce, under the same budget, on that later attempt too, refusing again for as long as the residual write stays outstanding and clearing the debt only once some attempt actually waits it out.

A whole-engine shutdown facing the same timeout only warns and counts it instead of refusing anything, since the process is exiting either way and a later process starts with its own storage connections, which the residual write from this one cannot reach.

From one raw event to zero or more deliveries

Inside Processor::process_one, run once per raw event (crates/blockwatcher-core/src/pipeline/processor.rs):

  1. An event addressed to a different network is dropped and counted (misrouted) rather than processed: a pipeline never touches an event that isn’t its own, because attributing it would corrupt this pipeline’s own cursor ordering.
  2. The current monitor set is snapshotted once for the whole event, not once per monitor, so a hot-swapped monitor set landing mid-event can never split that event’s processing between an old and a new set.
  3. For every active, unpaused monitor, the decoder runs against that monitor’s compiled selector. Zero, one, or several decoded events can result (an EVM log’s Transfer and a batched transaction’s several decoded calls are both possible); each one that decodes is evaluated against the monitor’s predicate (or accepted outright if it has none).
  4. Every monitor that accepts a decoded event offers that occurrence to its gate (or, with gate omitted, treats it as Emit of this hit). Retain / Discard mint nothing. Emit mints one match per chosen journal index (one SinkEvent::Match, or one Digest if two or more) and queues one work item per sink that monitor’s actions name.
  5. Once every pair from this one raw event is known, the event is registered with the progress tracker for exactly that many outstanding completions: registered exactly once per event, after dispatch, never before and never twice. A gate that retained or discarded adds zero to that count for that monitor (crates/blockwatcher-core/src/pipeline/processor.rs).

An event that matched nothing (no selector, or predicate false, or every gate quiet) still gets registered, with zero outstanding, which is what lets its cursor advance immediately rather than wait on deliveries that were never dispatched.

Delivery, retry, and the checkpoint’s dependency on it

Each sink worker pulls one work item at a time and calls deliver_with_retry (crates/blockwatcher-core/src/pipeline/delivery.rs): a transient or rate-limited failure sleeps a doubling backoff and tries again, up to the sink’s configured max_attempts; a permanent or range-narrower failure skips straight to giving up. Giving up means recording a dead letter in storage (retried forever on its own backoff if that write itself fails), because a checkpoint may only move past a loss storage has durably recorded, never past one it merely intends to record, the ordering SinkWorker::dead_letter’s own doc comment states (crates/blockwatcher-core/src/pipeline/sink_worker.rs). Only once delivery succeeds or the dead letter is durably written does the sink worker complete its guard. The checkpoint writer, in turn, only ever persists a cursor once every guard the processor handed out for it (across every monitor and every sink that event’s matches touched) has completed, via Progress and the CompletionGuard it hands out (crates/blockwatcher-core/src/progress.rs). Delivery guarantees covers this mechanism itself in depth, along with the one shipped source, evm-mempool, whose cursor is not a chain position and so cannot make the same promise across a crash.

Resources

Everything blockwatcher watches and everywhere it delivers to is one of its resource kinds, each its own Rust struct in crates/blockwatcher-types/src/resource.rs, each rejecting an unrecognized field at write time (#[serde(deny_unknown_fields)], one exception noted below). This page covers what each kind actually contains, how a monitor references the others, how a resource gets into storage in the first place, and exactly what gets checked before a write is allowed to land. The field-level tables (every key, its type, its default, and the exact refusal a bad value gets) live in the Resource reference, one page per kind.

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,sources,sinks,metrics,engine,decoder,matcher,gate dim
class storage,api 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

  • blockwatcher’s resource kinds are exactly Network, Spec, SinkDef, and Monitor, each rejecting an unrecognized field except Monitor’s selectors, checked later by the decoder.
  • A monitor is the only resource that references any other: it names one network, one spec per selector, and one or more sinks.
  • Every resource is a versioned record, written through the REST API’s ETag/If-Match concurrency or, once, through a first-boot seed directory.
  • What restarts on a write differs by kind: a monitor write hot-swaps in place, a network write always restarts, and a spec or sink write restarts every network it affects.
  • Every write validates before it reaches storage, and every validation rejection surfaces on the wire as 422.

The kinds and their fields

Network (Network, crates/blockwatcher-types/src/resource.rs): an id, a chain tag, and a source module selection (ModuleSel: a module name plus that module’s own opaque config object). Nothing else. It is the only resource whose module family (Source) is configured on the resource itself rather than referenced from elsewhere.

Spec (Spec): an id, a chain tag, and a payload that core never interprets: for the evm chain family this is a Solidity JSON ABI array, meaningless to anything outside that one chain’s decoder. The write itself is what runs EvmDecoder::compile_spec (crates/blockwatcher-evm/src/decoder/mod.rs), turning the ABI into a SchemaSet and a selector-keyed dispatch table exactly once; a selector or predicate written against this spec afterward resolves against that already-built result, so re-parsing the ABI text is a write-time cost this resource pays only once, not something every later selector compile repeats.

SinkDef (SinkDef): an id, a module name, that module’s config, an optional retry policy (DeliveryRetry { max_attempts, initial_backoff_ms, max_backoff_ms }), an optional throttle policy (Throttle { max_deliveries, window_ms }), and an optional aggregate policy (Aggregate { window_ms, max_batch }). All of them are siblings of config rather than nested inside it: a sink module never sees or interprets its own retry, throttle, or aggregate policy, the engine owns retry, dead-lettering, throttling, and aggregation identically for every module. A SinkDef with no retry falls back to the engine-wide default from blockwatcher.toml; a SinkDef with no throttle is not throttled at all, since throttling is opt-in; a SinkDef whose throttle field is present fills any field left out of it with the default, 60 deliveries per 60,000ms; a SinkDef with no aggregate delivers every match on its own, unbatched. See Delivery policies for what enforcing each one means, and for a complete SinkDef JSON example carrying all three beside a webhook module config.

Monitor (Monitor): an id, the network it watches, one or more selectors, an optional predicate, an optional gate (ModuleSel: module + config; omitted means passthrough), and actions (a list of sink ids). gate is this monitor’s decision rule (like predicate), not a shared resource id (like actions: SinkId[]). Journals are per (pipeline, monitor). RawSelector is deliberately the one resource shape without deny_unknown_fields: it carries a core-readable spec field plus a #[serde(flatten)] bag of decoder-owned keys (events, functions, addresses for the evm decoder), and serde cannot combine flatten with deny_unknown_fields on the same struct. An unrecognized selector key is still rejected, just later, by the chain’s decoder at compile time rather than by serde at parse time, in compile’s own unknown-key check (crates/blockwatcher-evm/src/decoder/selector.rs).

How they reference each other

A monitor is the only resource that names any of the others, and it names all of them:

erDiagram
    Network {
        NetworkId id
        ChainKind chain
        ModuleSel source
    }
    Spec {
        SpecId id
        ChainKind chain
        Json payload
    }
    SinkDef {
        SinkId id
        string module
        DeliveryRetry retry
        Throttle throttle
        Aggregate aggregate
    }
    Monitor {
        MonitorId id
        NetworkId network
        RawSelector[] selectors
        string predicate
        ModuleSel gate
        SinkId[] actions
    }
    Monitor }o--|| Network : "network"
    Monitor }o--o{ Spec : "selectors[].spec"
    Monitor }o--o{ SinkDef : "actions"

Monitor.gate is optional; mermaid cannot show Option cleanly, so omitted means passthrough, as Gates describes. Monitor.network names exactly one Network; each entry in Monitor.selectors names exactly one Spec through its spec field; and Monitor.actions names one or more SinkDefs. Network, Spec, and SinkDef never reference each other or point back at a monitor: the reference graph is a star with Monitor at the center, one level deep. A Spec and the Networks that use it are connected only indirectly, through whichever monitors select that spec on that network; spec_set_for_chain (crates/blockwatcher-core/src/compile.rs) compiles every spec sharing a network’s chain together, which is why a spec edit can affect a network that no monitor explicitly ties to it by name (see the write-time checks below).

Lifecycle: how a record is created, versioned, and reloaded by kind

Every resource is a VersionedRecord in storage: an id, a version for optimistic concurrency, and the JSON value (crates/blockwatcher-types/src/resource.rs). A record gets there by exactly the routes below, and by no other:

  • The REST API, PUT /{kind}/{id}. Without an If-Match header the write is a create: it fails with 409 Conflict if the id already exists. With If-Match: "<version>" it is an update: it fails with 412 Precondition Failed (naming the actual current version) if the version doesn’t match, or 404 if the record is gone, per Storage::put’s contract and the write handler’s call to if_match (crates/blockwatcher-ports/src/storage.rs, crates/blockwatcher-api/src/routes/resources.rs). A successful write’s response carries the resulting version in its own ETag, which is what the next conditional write is expected to send back as If-Match.
  • A seed directory, read once at boot via the binary’s --seed <dir> flag. It expects exactly one subdirectory per kind (networks/, specs/, sinks/, monitors/), one JSON file per resource, and it is strictly first-boot: seeding runs the whole bundle through the same validation Engine::start itself would run, then writes it with create-only semantics, and once storage holds any resource of any kind, seeding is refused as a no-op on every later boot, per validate and store_is_empty (crates/blockwatcher/src/seed.rs). After that first boot, every resource in that deployment is managed exclusively through the API.

What happens to a running pipeline differs by kind, and this is the one place “hot-reloaded” oversimplifies:

  • Writing a Monitor usually restarts nothing. It recompiles that monitor’s network’s whole MonitorSet from storage and publishes it to the already-running pipeline through a watch channel: the same source instance keeps running, its checkpoint keeps advancing straight through the swap, in ControlHandle::put_monitor (crates/blockwatcher-core/src/control/writes.rs). Two things turn that swap into a restart of the one network:

    • Changing gate (module or config) is a new compiled artifact and drops that monitor’s gate journal. Holds belong to one envelope, and a processor keeps the journal of every gated monitor it is running in memory, so the drop may only happen with nothing processing for that network: the pipeline is stopped first, the holds dropped, and the pipeline brought back, in wipe_gate_state_while_stopped (crates/blockwatcher-core/src/control/mod.rs). Deleting a gated monitor takes the same path, per delete_monitor (crates/blockwatcher-core/src/control/deletes.rs). The wipe drops holds (undecided accumulation), never committed emissions: an outbox row the old envelope emitted but had not yet delivered survives and is delivered (or dead-lettered) when the pipeline comes back, per the gate delivery guarantee.
    • If the recompiled set now names a sink the running pipeline never spawned a worker for, the write escalates to a full restart, because a sink worker cannot be added to a pipeline after the fact, per hot_swap_monitors’s own doc comment (crates/blockwatcher-core/src/engine/control.rs).

    Both cost only what any restart costs: the source resumes from the checkpoint the drain left behind. A network with no running pipeline is never brought up by either: there is nothing to protect, and starting one would undo whatever stopped it.

  • Writing a Network always restarts that network’s pipeline: the old one is drained, a fresh one is spawned, and it resumes from whatever checkpoint the drain left behind, in put_network (crates/blockwatcher-core/src/control/writes.rs). A restart is cheap specifically because the checkpoint survives it; it is not a re-scan from the beginning.

  • Writing a SinkDef restarts every network whose stored monitors currently name that sink id, not every network in the deployment, per put_sink (crates/blockwatcher-core/src/control/writes.rs).

  • Writing a Spec restarts every network on either the spec’s new chain or (on a reassignment) its prior chain, because spec_set_for_chain compiles every spec sharing a chain together: a network that never named this spec by id can still be running a compiled set built partly from it, per put_spec (crates/blockwatcher-core/src/control/writes.rs).

A restart that fails part-way through a multi-network fan-out (a SinkDef or Spec write affecting several networks) does not fail the request or stop the ones after it: the resource is already durably stored either way, and each network’s own restart failure is only logged, in restart_affected (crates/blockwatcher-core/src/control/writes.rs).

Whatever triggers it, a restart has the same observable side effects: a network write, a spec or sink write, a gate-envelope change, a gated monitor’s delete, and reorg recovery all go through this same replacement. Each one resets the /status counter snapshot for that network and rebuilds its sink workers, so in-memory throttle and aggregate windows start fresh; the Prometheus counters, checkpoints, journals, and outbox are unaffected, since none of them live in the pipeline instance a restart replaces.

Write-time validation

Every write validates before it ever reaches storage, and every rejection is 422 Unprocessable Entity on the wire: classify, in crates/blockwatcher-api/src/error.rs, classifies exactly this set of engine refusals that way, distinguishing them from a version conflict (412), a missing reference lookup (404), or a genuine server fault (500, and never with internal detail on the wire). What gets checked differs by kind:

  • Monitor: its network must exist; every id in actions must name an existing sink; every selector’s spec must exist and share the network’s chain; and the whole monitor (selectors, predicate, and gate) must compile against the live decoder, matcher, and gate catalog before the write is persisted, in put_monitor (crates/blockwatcher-core/src/control/writes.rs). An unknown gate module, unknown config keys, out-of-range window_ms/count, or missing block.timestamp is 422: gate requires 'block.timestamp'; this monitor's selectors do not expose it.
  • Network: its chain must have a loaded decoder, and its source module must actually construct with the given config, not merely be a name the catalog recognizes. Updating an existing network additionally recompiles every monitor already stored for it against the incoming chain, refusing a chain reassignment that would strand them, in put_network (writes.rs).
  • SinkDef: its module must actually construct with the given config. Nothing about existing monitors is re-validated: a sink id, once it exists, is a stable dependency by name, per put_sink (writes.rs).
  • Spec: its chain must have a loaded decoder, and the payload must compile standalone; then every monitor on every network that would be affected (per the chain-sharing rule above) is recompiled against a hypothetical spec set with this write already applied, so a reassignment that would break one of them is refused before it is stored, in put_spec (writes.rs).
  • Delete, for Network/Spec/SinkDef, refuses outright while any stored monitor still references the id (checked before storage is touched at all), because a monitor left pointing at nothing would not fail on its own; it would fail the next boot for the whole deployment, per refuse_if_referenced (crates/blockwatcher-core/src/control/deletes.rs).

The did-you-mean suggestion

A 422 for an unresolvable name (an event or function a selector names that the spec doesn’t declare) carries a suggestion when one is available. For a selector’s events/functions list, the suggestion is simply the first event or function of that kind the spec declares, kind_suggestion (crates/blockwatcher-evm/src/decoder/selector.rs); it is not an edit-distance match. A predicate’s unknown field or namespace, covered on Predicates, gets a real bounded edit-distance suggestion instead: the mechanisms are deliberately different, one per crate that owns the vocabulary being checked. Either way the message shape is the same: SelectorError::UnknownField and PredicateError::UnknownField both render as unknown field '{field}', followed by a did you mean '{suggestion}'? suffix when one exists (crates/blockwatcher-ports/src/error.rs); for example, naming "Transfr" in a selector against a spec that declares Transfer and Approval rejects with unknown field 'Transfr' plus did you mean 'Approval'? (the suggestion is the spec’s first declared event of that kind, not the closest by spelling).

Predicates and the expression language

Every predicate a monitor carries reduces one decoded occurrence to a single yes-or-no answer, deciding whether it becomes a match. The whole language it’s written in lives in crates/blockwatcher-expr: a lexer, a recursive-descent parser, a type-checker that runs at write time against a monitor’s selector schemas, and a total evaluator that runs at decode time and can never error. This page enumerates the grammar from the parser itself, explains the type system it checks against, and walks compilation and evaluation end to end. Every predicate shown below was checked against the parser and type-checker described here, against a schema this page defines in The example schema below.

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,sources,sinks,storage,api,metrics,engine,decoder,gate dim
class matcher 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 predicate compiles once, at write time, into an AST checked against the monitor’s schema; evaluation at decode time is total and can never error.
  • The type system groups values into families (Int, Str, address/bytes, Bool) rather than exact types, so a heterogeneous field can carry more than one family at once.
  • Evaluation is three-valued (True, False, Unknown) with Kleene logic for &&/||, and a predicate treats Unknown the same as False.
  • An unknown field or namespace gets a bounded edit-distance suggestion within distance 3; beyond that there is no suggestion at all.
  • expr is the one matcher this repository ships, selected once for the whole process rather than per monitor.
  • A predicate is per-event. Counting, windows, and “already alerted” are a gate, not an expression.

Compilation: what happens when a monitor is written

Compilation happens once, at write time; evaluation happens on every decoded event, against the artifact compilation already produced:

flowchart LR
    subgraph write["write time, once"]
        text["predicate text"] --> parse["parser::parse"] --> ast["Ast"]
        ast --> check["typecheck::check<br/>against SchemaSet"]
        check --> compiled["compiled Predicate"]
    end
    subgraph hot["decode time, every event"]
        compiled --> eval["eval::evaluate(ast, event)"]
        eval --> truth["True / False / Unknown"]
    end

Predicate::compile (crates/blockwatcher-expr/src/lib.rs) does exactly two things, in order: parser::parse turns the source text into an Ast, and typecheck::check walks that tree against the monitor’s SchemaSet, resolving every field path and admitting or rejecting every operator’s operand types. Either stage can fail with a PredicateError, and either failure is what a monitor write rejects with. A predicate never reaches storage unparsed or untyped. A Predicate that compiled successfully cannot fail to evaluate: matches (lib.rs) is eval::evaluate(&self.ast, event) == Truth::True, a total function over the type-checked tree. This is also why the same text can mean two different, unrelated things depending on which monitor it’s attached to: the schema it type-checks against comes entirely from that monitor’s own selectors, never from a shared global vocabulary. The expr module (crates/blockwatcher-expr/src/matcher.rs) is the one Matcher this repository ships, and blockwatcher.toml’s matcher field names it for the entire process at boot: one choice for every network and every monitor the deployment runs, not a per-monitor setting. Nothing about the port boundary itself limits a deployment to this one engine; it is simply the only implementation in the module catalog blockwatcher ships (see Modules). The pipeline covers where in a running pipeline this compiled predicate actually runs.

The example schema

Every predicate example on this page type-checks against one small schema: a spec declaring two ERC-20-style events plus one made-up event for string and array examples, alongside the tx/block/log namespaces the evm decoder’s namespaces function attaches to every compiled spec (crates/blockwatcher-evm/src/decoder/compile.rs):

args (from the selected event)
  Transfer:  from address, to address, value uint256
  Approval:  owner address, spender address, value uint256
  Registered: owner address, name string, tags string[]
tx:    hash bytes, index uint, status uint, from address, to address, value uint
block: number uint, hash bytes, timestamp uint
log:   address address, index uint

args.* is always derived from whichever event or function the monitor’s selectors decoded: it is never declared separately. tx.*, block.*, and log.* are the same three namespaces on every evm spec, and which of their fields an occurrence actually carries depends on the selector that produced it: a log-decoded event’s tx.status is always the constant 1 (a log exists only in a transaction that succeeded) and it never carries tx.from/tx.to/tx.value, while a function-call-decoded occurrence carries those but only carries tx.status/tx.index/block.* once the transaction is mined, never on evm-mempool. A predicate reading a field this occurrence doesn’t carry resolves Unknown, covered under Evaluation below.

Syntax by example

args.value > 1_000_000e6

args.value is declared uint256, so it’s an integer; 1_000_000e6 is token-decimal notation: one million at six decimals, expanded exactly to 1000000000000 at parse time by expand_token_decimal (crates/blockwatcher-expr/src/lexer.rs). This reads “more than one million whole units of a six-decimal token.”

tx.from != 0x0000000000000000000000000000000000000000 && args.value > 0

tx.from is declared address; the hex literal is a byte string compared byte-for-byte, regardless of length: lex_hex (lexer.rs) doesn’t require 20 bytes or any other specific length, only an even digit count. && requires both sides to admit Bool, which both comparisons do.

"promo" in args.tags

args.tags is declared string[]; in’s left operand is a scalar, its right operand here is a path resolving to an array whose element type (Str) intersects the left operand’s family.

args.name contains "USDC" || args.name starts_with "test-"

Both contains and starts_with require Str on both sides; args.name is declared string.

args.value % 1_000_000 == 0

% requires both operands to admit Int; 1_000_000 is a plain integer literal (no exponent), and dividing or taking the modulus of a literal zero is rejected at write time by bin_type_arithmetic (crates/blockwatcher-expr/src/typecheck/mod.rs). A runtime zero divisor, by contrast, resolves to Unknown rather than panicking (covered below).

One rejection worth showing alongside the working examples, because it’s the language’s one surprising parse rule: comparison operators don’t chain.

1 < args.value < 100

This is refused at parse time with comparisons do not chain; parenthesize (crates/blockwatcher-expr/src/parser.rs:262-282) rather than silently reading as either (1 < args.value) && (args.value < 100) or the mathematically different (1 < args.value) < 100. Write it as args.value > 1 && args.value < 100 instead.

Grammar and precedence

The parser is recursive descent, one function per precedence level, from loosest binding to tightest (crates/blockwatcher-expr/src/parser.rs):

  1. ||
  2. &&
  3. ! (prefix; its operand is itself parsed at this level, so ! chains, and it wraps an entire comparison rather than binding inside one: !a.x == 1 parses as !(a.x == 1), not (!a.x) == 1)
  4. Comparisons: == != < <= > >= in starts_with ends_with contains (non-chaining, exactly as shown above)
  5. + -
  6. * / %
  7. Unary -
  8. Atoms: integer/hex/string/boolean literals, field paths, (...)

in’s right-hand side is special-cased in the grammar itself, inside parse_in_rhs (parser.rs): it accepts only a literal list ([...], elements literals-only, one level of nesting so an address allowlist of any width never counts as “deep”) or a bare field path, never an arbitrary expression, so args.x in [1 + 2] is rejected as a list-syntax error before type-checking ever runs.

Operators and functions

CategorySpellingOperand requirementResult
Boolean|| && !BoolBool
Equality== !=both sides’ scalar families intersect (IntInt, StrStr, bytes↔bytes, BoolBool)Bool
Ordering< <= > >=both sides IntBool (non-chaining)
Membershipinleft: a scalar; right: a literal list, or a path resolving to an array whose element family intersects the left’sBool
Stringstarts_with ends_with containsboth sides StrBool
Arithmetic+ - * / %both sides IntInt (/, % truncate toward zero; a literal-zero right side is a compile-time rejection)
Unary- (negation)IntInt

This table is exhaustive against crates/blockwatcher-expr/src/parser.rs’s BinOp enum (Or, And, Eq, Ne, Lt, Le, Gt, Ge, In, StartsWith, EndsWith, Contains, Add, Sub, Mul, Div, Mod) plus the unary node kinds, Not and Neg. There is no function call syntax, no user-defined name, and no loop construct anywhere in the grammar. starts_with/ends_with/contains/in are keywords lex_ident recognizes, not calls (lexer.rs), which is why the language has no general extensibility surface beyond what the parser hard-codes.

Literal forms

FormExampleNotes
Integer1_000_000arbitrary precision; _ separators anywhere in the digit run, may repeat or trail (1__0, 1_ both lex to 10 and 1)
Token-decimal1_000_000e6, 1.5e18exact integer expansion: mantissa (optionally with a fractional part) times 10^exponent; the fraction must fully resolve (1.5e0 is rejected: not an integer); exponent capped at 100
Hex bytes0xA0b8even, non-zero digit count; any length, not just 20-byte addresses
String"USDC"double-quoted; only \" and \\ are recognized escapes
Booleantrue / false
List[1, 2, 0xA0]literals only (optionally negated integers); appears only as in’s right-hand side

Field paths

A path is namespace.field, optionally followed by more .segments, parsed by parse_path (parser.rs). Two things resolve past the first dot, in order, inside SchemaSet::resolve (crates/blockwatcher-types/src/schema.rs):

  • A dotted flattened name, matched whole. A decoder that flattens a nested ABI parameter into order.maker is what makes args.order.maker resolve: the whole remainder after the namespace is one field name, checked as a unit, not traversed component by component.
  • A trailing run of digit segments, derived through a declared array type. args.tags.0 needs no declaration of its own; it resolves through tags’s declared Array(Str) to Str, one array layer unwrapped per digit segment. A digit segment too large to fit a usize is rejected: such an index could never be read back at evaluation time, so admitting it would compile a predicate that resolves to Unknown forever.

A flat declaration always wins over derivation when both would apply to the same spelling: a decoder’s explicit word for what it emits takes priority.

The type system: families, not exact types

The type-checker’s own vocabulary, the Family enum (crates/blockwatcher-expr/src/typecheck/mod.rs), is coarser than the canonical value model: Int and Uint collapse into one family, Int, because every arithmetic and ordering operator treats them identically; Address and Bytes collapse into one family too, because a predicate only ever compares them as raw bytes. A path can carry more than one family at once when heterogeneous selectors declare the same name at different types: a monitor with two selectors, one where args.id is a uint256 and another where it’s a bytes32, type-checks an operation against args.id if either declared type admits it.

Two consequences worth knowing:

  • A negative literal against an all-Uint field never gets as far as running. args.value == -1 against a field declared only uint256 fails to compile with 'args.value' is unsigned and can never satisfy this comparison (checked in crates/blockwatcher-expr/src/typecheck/mod.rs:182-204, the message itself built by diagnostics.rs:282-293). An unsigned value can never equal or be less than a negative number, so the predicate would never fire, and that’s caught before it’s ever stored rather than discovered later as a monitor that silently never matches. The opposite direction (args.value > -1, always true for a Uint) compiles: it’s loud in a different way, matching everything, which is visible, unlike a predicate that matches nothing.
  • A type error names the schema field it’s about whenever one side of the failure is a path, and otherwise names the computed families on both sides. args.name + 1 (a Str field used in arithmetic) reports 'args.name' is declared as [Str], which does not support arithmetic; 1 > "x" (neither side a field) reports ordering requires an integer, but a string was given.

Evaluation: three-valued, not two

Evaluation (crates/blockwatcher-expr/src/eval.rs) never errors and never panics. Every subexpression reduces to one of three truth values (True, False, or Unknown), and Unknown has exactly three sources: a field the occurrence doesn’t carry at all, an explicit null, or a runtime value that the operation in question cannot digest (possible only through a heterogeneous field, since the type-checker already confirmed some declared type admits the operation). matches() collapses Unknown the same way it collapses False (no match), which is what makes a predicate over a field only some of a monitor’s selectors produce safe to write: an event that never carries the field simply never satisfies that clause, on either side of a negation.

&& and || follow the standard three-valued (Kleene) tables rather than treating Unknown as either extreme:

aba && ba || b
TrueUnknownUnknownTrue
FalseUnknownFalseUnknown
UnknownUnknownUnknownUnknown

The practical effect: False && Unknown is False (nothing on the unresolved side could rescue a conjunct that already failed), but True || Unknown is True for the same reason in reverse. Neither of those outcomes requires evaluating the Unknown side at all, so evaluate short circuits exactly where the table already has an answer (eval.rs).

Integer comparisons are exact at every width the canonical value model carries: Value::Int/Value::Uint wrap num-bigint’s arbitrary-precision types, so a comparison against a number one above u128::MAX is not approximated: it is pinned directly in blockwatcher-expr’s own test suite (comparisons_are_exact_past_u128, eval.rs), and Int and Uint compare mathematically regardless of which one a decoder happened to spell a value as. Division and modulus truncate toward zero; a runtime zero divisor (reachable only through a heterogeneous field or an arithmetic expression whose value depends on the event) resolves to Unknown, never a panic.

Errors and the did-you-mean suggestion

Every PredicateError variant carries an operator-facing message, and two (UnknownField and UnknownNamespace) carry a suggestion when one exists. The mechanism is suggestion’s bounded edit-distance search (crates/blockwatcher-expr/src/typecheck/diagnostics.rs): every field the resolved namespace actually declares is compared against what was typed, a two-row dynamic-programming pass that bails out the moment a candidate provably can’t come in under the threshold, and the closest candidate within distance 3 is offered. Ties keep whichever declared name comes first. Given this page’s example schema, args.vlaue > 100 (a five-character transposition, edit distance 2 from args.value) is rejected at write time with unknown field 'args.vlaue' — did you mean 'args.value'? (the em-dash is part of the literal error text, verbatim from the code that emits it). Beyond distance 3 there is no suggestion at all: a wildly wrong name gets a plain rejection rather than a misleading nudge. A namespace typo (argss.value) gets the list of every namespace this monitor’s schema actually declares instead of a single guess, since there’s no declared spelling to measure distance against: the operator wrote a namespace, not a typo of a field.

This is a different mechanism from the one Resources describes for an unknown event or function name in a selector: that suggestion is simply the spec’s first declared name of the right kind, not an edit-distance match. The two live in different crates (blockwatcher-expr for predicates, blockwatcher-evm for selectors) because they check different vocabularies against different failure shapes.

Gates

A predicate answers a per-event yes-or-no. A gate answers a question that depends on other hits of the same monitor: did Transfer occur three times in the last hour of event time? Has this monitor already alerted in this window? The engine asks that question after selector and predicate succeed, and before a match exists.

Omit gate and the engine behaves as it does today: every predicate-true decode is a match. Name a gate module the same way a sink names its module ({ "module": "threshold", "config": { … } }), and the engine offers each hit to that compiled module together with an engine-owned journal of earlier holds.

Delivery guarantees still describe what happens after a match (or digest) is minted. This page is the seam before that mint. Sink throttle and aggregate are the wrong layer for “count three Transfers”: they run after a Match already exists, and aggregate stalls the checkpoint for the whole window.

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,sources,sinks,storage,api,metrics,engine,decoder,matcher dim
class gate 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 gate is a sixth port: one compiled decision per monitor over an engine-owned hit journal. The module never opens sqlite.
  • Retain / Discard complete with zero outstanding: holding a hit does not stall the checkpoint. Only an Emit journals a delivery.
  • Window membership uses decoded block.timestamp (unix seconds), never wall-clock time. Catch-up does not dump history into “now.”
  • On invalidate the engine prunes journal rows with cursor strictly after from. It does not drain the whole bag. A tip reorg must not wipe holds from before the fork.
  • One gate per monitor. Compose behaviours with two monitors. Per-event filters stay in the predicate.

Assumptions (what the operator is agreeing to)

AssumptionPrecise meaning
Event time, never wall timeWindow membership uses decoded block.timestamp (unix seconds in Value). Catch-up, a paused pipeline, and tokio::time::Instant do not move a window.
One gate per monitorThe resource field is gate, a single { "module", "config" } envelope. There is no gates: []. Two behaviours means two monitors (same selectors, different gates, different actions if needed).
Omit = passthroughNo gate field means the engine emits this hit as today. No passthrough module is constructed at runtime.
After predicate, before a Match existsA gate never sees a decoded event the predicate rejected. A Retain/Discard is not a Match, not a dead letter, not a sink event.
Decoder vocabularyConfig paths (block.timestamp, later args.from) are the same schema the predicate uses. The gate crate does not parse ABI, logs, or RPC.
block.timestamp required for time-window gatesA monitor whose compiled schemas do not expose block.timestamp as unsigned / non-negative int cannot name threshold or max_once. The write is 422, not a runtime skip.
Mempool / no header timeA source that never puts block.timestamp in the tree cannot use a time-window gate. That is a decoder/schema fact, not a special case in core.
Engine owns the journalModules do not open sqlite, do not hold a HashMap of hits across calls as the source of truth, and do not flush “all RAM” on invalidate.
Hot-swap does not migrate holdsChanging module or config, or deleting the monitor, deletes that monitor’s gate_hits / gate_meta. Old holds are not rewritten into the new compiled gate.
Dry-run is inertPOST /monitors/{id}/test uses a fresh empty journal, does not read stored holds, does not persist, does not call sinks, does not move a checkpoint.
No clock without a hitNo on_tick. No absence, debounce, or “flush the hour.” Quiet is only a decision on a predicate-true hit.
Matcher stays process-wideGate selection is per monitor, like a sink. The matcher language is still one engine-wide module.

Guarantees (what the engine actually promises)

GuaranteePrecise meaning
Quiet hits do not stall the checkpointRetain and Discard register zero outstanding for this monitor’s sinks. Other monitors on the same raw event still add their own outstanding.
Persist-before-completeThe resulting journal (+ meta) is persisted before Progress::begin for an Emit, and before completing a quiet hit.
Persist failure stalls, never skipsStorage put failed: begin(cursor, 1) and the guard is not completed (same stall as a closed sink channel). The engine does not skip begin (that would let a later cursor strand this one).
Prune-by-cursor on invalidateAfter drain, DELETE hold rows with cursor > from for that pipeline. Rows with cursor ≤ from stay. Then today’s retract pass for already-emitted Matches.
Tip reorg does not wipe pre-fork holdsDrain-all / “clear the bag on any Invalidated” is forbidden. Replay only re-feeds events after from; wiping earlier holds would silently under-count.
Replay is at-least-onceAn Emit that already happened may happen again after rewind; Match ids are deterministic. Consumers dedupe as they do today.
max_once discards are not dead lettersAn in-window later hit is dropped with no sink event and no dead-letter row. Throttle’s “suppressed but replayable” contract does not apply here.
threshold session resetAfter an Emit of N hits, those rows leave the journal. Leftovers stay. T4–T6 after a 3-fire can form a second digest without waiting out the original hour.
Engine mints idsThe module returns indices into the journal. Core calls existing Match::new. One index → SinkEvent::Match; two or more → SinkEvent::Digest.
Cap is visibleMore than 10_000 hold rows for one monitor: drop oldest, count blockwatcher_gate_hits_dropped_total. Not silent.
Untimestamped at runtime is counted, not stalledA predicate-true hit whose block.timestamp is missing or unusable is not inserted, outstanding 0, counted blockwatcher_gate_untimestamped_total. Write-time schema check is the closed door; this counter is a decoder/runtime hole.
Invalid on_hit after compile does not stallTreat as Discard of this hit only, count blockwatcher_gate_decision_invalid_total. Never a third outcome that wedges Progress.
Chain-agnosticCore and gate crates do not match ChainKind, import SDKs, or parse RPC payloads.

Contrast operators will get wrong

WantUseDo not use
Fire only when N predicate-true hits span ≤ W (event time); then start a new sessionMonitor gate.module = thresholdSink aggregate (holds the checkpoint until the window closes; batches Matches that already exist)
At most one alert per event-time window; later hits vanishMonitor gate.module = max_onceSink throttle (caps deliveries after a Match exists; refusals are dead letters you can replay)
Drop this event because it is odd / before noon UTCpredicate (block.number % 2, block.timestamp % 86400)A gate. Per-event filters are not stateful.
Rate-limit a noisy webhook without losing matchesSink throttlemax_once (those hits are gone, not replayable)
Batch already-fired matches into fewer HTTP postsSink aggregatethreshold (threshold decides whether a Match exists)

Where it sits

flowchart LR
    src[Source] --> dec[Decode]
    dec --> pred[Predicate]
    pred --> gate[Gate]
    gate --> sink[Sink]

Inside Processor::process_one (crates/blockwatcher-core/src/pipeline/processor.rs), after a monitor accepts a decoded event:

  1. If the monitor has no gate, mint a match as today.
  2. If it has a gate, load that monitor’s persisted hits, append this hit, call Gate::on_hit, persist the resulting journal, then either stay quiet or mint SinkEvent::Match / Digest from the indices the module returned.
  3. Quiet (Retain or Discard) registers outstanding 0 for this monitor’s sinks. Other monitors on the same raw event still add theirs.
  4. The raw event still registers with Progress exactly once, after every monitor has been considered, for the total outstanding across all of them, including zero.

A hit the gate kept is not a match. Sinks never see it. Dead-letter replay cannot resurrect it. The only durable trace is the gate_hits row (and the metrics on Observability).

Resource shape

On Monitor, sibling of predicate (crates/blockwatcher-types/src/resource.rs):

{
  "id": "usdc-burst",
  "network": "eth-mainnet",
  "selectors": [{ "spec": "erc20", "events": ["Transfer"] }],
  "predicate": "args.value > 0",
  "gate": {
    "module": "threshold",
    "config": { "count": 3, "window_ms": 3600000 }
  },
  "actions": ["alerts"]
}
"gate": { "module": "max_once", "config": { "window_ms": 3600000 } }

gate is this monitor’s decision rule (like predicate), not a shared resource id (like actions: SinkId[]). Journals are per (pipeline, monitor). The envelope is ModuleSel-shaped: module plus config object, deny_unknown_fields on the envelope. The named module validates config at write (Modules, P9). An unknown module name is 422, listing catalog names, the same as an unknown sink module.

gate omitted: no module is constructed; the engine emits the hit.

There is no gates array. Order of stacked gates would be a footgun (max_once before threshold never reaches count 3). Two monitors with the same selectors and different gates are how an operator composes behaviours.

Decisions: Retain, Discard, Emit

Gate::on_hit (crates/blockwatcher-ports/src/gate.rs) is total after a successful compile, the same way Matcher::matches is total after compile. It sees the journal including this hit as the last element and returns exactly one of:

DecisionJournalDeliveryProgress for this monitor
RetainKeep the whole bag, including this hitNoneoutstanding 0, after persist
Discard { indices }Drop those rows; no sinkNoneoutstanding 0
Emit { indices }Remove those rowsMatch if one index, Digest if two or more; engine mints idsoutstanding = this monitor’s action count

The module returns indices, never a payload it invented. Core calls existing Match::new on each chosen DecodedEvent.

A buggy on_hit after compile is counted (blockwatcher_gate_decision_invalid_total) and treated as Discard of this hit only. It does not stall the pipeline.

Event time

Time-window gates require block.timestamp in the compiled schema (unsigned or non-negative int). Otherwise the write refuses:

gate requires 'block.timestamp'; this monitor's selectors do not expose it

Window membership:

(newer_ts.saturating_sub(older_ts) as u128) * 1000 <= window_ms

window_ms is 1..=86_400_000 (one day, inclusive). threshold.count is 2..=10_000.

A predicate-true hit that still has no usable timestamp at runtime is not inserted, does not stall, and increments blockwatcher_gate_untimestamped_total. That counter is a decoder hole the write-time check is supposed to have closed; it is not a third way to configure a gate.

Shipped modules

Both crates depend on types + ports only. Catalog family gate, same register path as sinks (build_catalog in crates/blockwatcher-embed/src/catalog.rs).

threshold: session digest

Config: { "count": u32, "window_ms": u64 }.

On a hit, drop a prefix until the remaining span fits window_ms (those drops are internal, not operator-visible, not dead letters). If len >= count, Emit the oldest count indices. Else Retain.

Worked example, count: 3, one-hour window:

  • T1@5m, T2@20m, T3@30m → one digest [1, 2, 3]. Journal empty of them.
  • T4@45m, T5@50m, T6@55m → second digest [4, 5, 6].

That is reset-on-fire, not “wait out the original hour,” and not a cooldown that would drop T4–T6. Leftover hits that were not part of the emitted N stay in the journal.

max_once: first hit per window

Config: { "window_ms": u64 }.

If last_emit_ts is none, or this event_ts is outside the window from last_emit_ts, Emit([this]) and record last_emit_ts. Else Discard([this]). The bag does not grow.

The payload is the first hit as a Match, not a digest of everything seen in the hour. Later in-window hits are gone: not dead-lettered, not replayable. If the operator needs those hits later, that is a different product (follow-up), not throttle.

Passthrough

Omit gate. Tests also ship an in-memory fake that always Emit([this]); it is not an operator-facing module in a default binary the way threshold is.

Persistence and crash

Storage (sqlite schema v4 and every Storage impl, including memory and the ports fake) keeps:

  • gate_hits: (pipeline, monitor, cursor_primary, cursor_secondary, event_index) plus event_ts and the decoded event JSON.
  • gate_meta: (pipeline, monitor)last_emit_ts, last_emit_cursor, optional opaque aux blob.

Identity is those columns, never a string-joined key.

After insert, if that monitor has more than 10_000 rows, the oldest are dropped and blockwatcher_gate_hits_dropped_total ticks.

Persist the new journal before completing the event. An Emit writes the journal and meta in one storage transaction so a crash cannot drop the cooldown after the emitted rows are already gone. A persist failure begins Progress with outstanding 1 and does not complete the guard: the checkpoint stays on this cursor, same as a closed sink channel. Skipping begin would let a later cursor complete and strand this one behind it.

Restart: sqlite still has the holds; the next hit sees them. Memory storage does not survive a process exit, matching its own contract.

Delete or rewrite a monitor (including changing gate.module or config): delete that monitor’s gate_hits and gate_meta. Holds are not migrated across compiled artifacts.

Invalidate

After drain, before rewind/restart, for that pipeline:

  1. Delete gate_hits rows whose cursor is strictly after from.
  2. Clear last_emit_ts / last_emit_cursor only when last_emit_cursor is strictly after from. An empty journal is not stale: after Emit those rows are gone on purpose, and a tip reorg must not reset max_once. A reorg of the emitted hit itself (cursor after from) does clear the cooldown so the replacement on the new fork can fire.
  3. Existing delivery-journal retract: Retracted for already-emitted Matches after from, as Delivery guarantees already describes.

Replay from from feeds on_hit again. Duplicate Emit is at-least-once; Match ids are deterministic.

Forbidden: DELETE FROM gate_hits WHERE pipeline = ? with no cursor predicate. A two-block tip reorg must not wipe holds with cursor ≤ from. Replay only re-emits after from; those earlier holds would never come back, and a later threshold would under-fire.

Gate::on_invalidate exists for follow-up aux (e.g. forever-distinct keys). threshold and max_once do not need it. The engine has already pruned holds before that hook runs; a module must not use the hook to drain the journal.

Dry-run

POST /monitors/{id}/test stays inert: no persist, no sink, no checkpoint. It constructs a fresh empty journal for the request and feeds payloads or fetch events in order. It does not read stored gate_hits. A three-payload body can fire a threshold digest. A single payload against threshold with count: 3 reports a retained hit and an empty matched list.

TestReport reports flattened emitted Matches (existing clients still see the events that would have been delivered) plus held (Retain decisions) and dropped (Discard decisions) without emitting.

What a gate is not

  • Not a predicate. “Odd block number” / “before noon UTC” belong in predicate (block.number % 2, block.timestamp % 86400).
  • Not sink throttle. Throttle caps deliveries after a Match exists and dead-letters the rest (replayable). max_once drops later hits.
  • Not sink aggregate. Aggregate batches Matches and stalls the checkpoint until the window closes. A 60-minute aggregate window is a 60-minute checkpoint stall. A 60-minute threshold window is not.
  • Not on_tick. A gate cannot fire because “an hour passed with no hit.” That needs a timer-driven path and a checkpoint-without-cursor spec, which this version does not have.
  • Not module-owned storage. A gate crate that opens sqlite, or that clears all RAM on any reorg, is not this port.

Chain-agnosticism

The gate sees Cursor and canonical Value only. event_ts is the block.timestamp path the decoder already put in the tree. Chain-agnosticism is unchanged: core does not learn a chain to “do windows.” A decoder that does not expose block.timestamp cannot use a time-window gate; the write fails closed.

Sources must provide event timestamps

A time-window gate windows on decoded block.timestamp, which presumes a block: a feed of not-yet-mined data has none to stamp. Each source module declares SourceCaps { event_timestamps: bool } (crates/blockwatcher-ports/src/source.rs). evm-rpc declares true; evm-mempool declares false, since a pending transaction has not been included in a block yet.

PUT /monitors/{id} checks the target network’s source caps against what the compiled gate needs, the same 422 compile_failed class every other compile refusal in this document uses. A gated monitor is invalid configuration on a network whose source does not declare event_timestamps, not a monitor that silently never fires. The same check runs the other way: PUT /networks/{id} refuses to swap a network’s source module out from under an existing gated monitor, because doing so would leave that monitor pointed at a feed its gate can no longer window against. Neither route lets the pairing exist for even one write.

Delivery guarantee

An emission’s durability starts inside the same storage transaction that consumes the journal members it was built from: Emit does not exist as a decision without also existing as a row a sink can still be delivered from. The sink worker deletes that row only once the delivery outcome itself is durable: landed in the delivery journal, or recorded as a dead letter, never before. A crash between those two points redelivers on the next start rather than losing the emission: duplicates are possible, gaps are not. A pending emission outlives the monitor that minted it: deleting the monitor leaves its outbox row in place, and the row is still delivered, or dead-lettered when its sink no longer has a worker, on the next start. Deleting the emission’s network dead-letters the row right away instead, because a deleted id gets no next start for the drain to run at: each contained match becomes a dead letter for the pipeline id, and the dead-letter surface keeps answering for any id that still holds letters, so they stay listable, replayable, and discardable while the network stays deleted. Whatever that delete-time sweep could not finish (a storage fence that did not resolve, a lapsed budget, a failed write) is dead-lettered by the next boot’s orphan sweep, which also covers rows stranded before the sweep existed. Every pipeline’s pending rows are counted by /status as gate_outbox_depth, read fresh from storage, so an emission parked behind a paused network stays visible until a resume delivers it. See Delivery guarantees for the outbox’s shape and the startup drain that sweeps whatever a crash left behind. Invalidate reaches into a pending emission’s row the same way it reaches into the hit journal: reorged-out members of the row are pruned at invalidation, and their canonical replacements re-accumulate through the gate on replay.

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.

Delivery guarantees

The pipeline named the Progress tracker as the thing that turns “every dispatched match done” into “safe to persist this cursor.” This page opens that mechanism up: what “done” means precisely, what happens when it never becomes true, and what a crash actually costs a consumer versus what it never costs them. Delivery scenarios is this page’s companion for a different question: given a monitor, a sink, and whatever policies are configured on it, what actually happens in the situation in front of you right now, walked as flow diagrams rather than field-by-field prose.

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,sources,api,metrics,engine,decoder,matcher,gate dim
class sinks,storage 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 checkpoint only ever advances past the longest contiguous run of fully-done events from the front; one stuck event holds back everything behind it.
  • Overload is backpressure, not data loss: bounded channels stall the pipeline rather than dropping a message.
  • A crash can only repeat work, never skip it, because a cursor only advances once every match tied to it is finished.
  • Deterministic match ids let a consumer treat a redelivered duplicate as the same occurrence rather than a new one.
  • A delivery that exhausts its retry budget becomes a durably recorded dead letter, and the guard only completes once that write lands.
  • Sinks consume a closed SinkEvent set: Match to apply, Retracted to undo by match_id, or Digest to apply several matches bundled by aggregation at once. All three are at-least-once; retracts and digests share the same retry and dead-letter path as a plain match.
  • A deep invalidate is a typed source outcome, not a failure: the engine drains, retracts journaled ids, rewinds, then restarts. Retracts for a sink finish before any replacement Match from that network.
  • A monitor gate decides whether a predicate-true hit becomes a match at all. Quiet gate hits are not deliveries, not dead letters, and do not hold the checkpoint. Sink throttle / aggregate still apply only after a match exists.

The contract, precisely

A network’s checkpoint is a cursor plus whatever the source needs to verify a resume. Progress (crates/blockwatcher-core/src/progress.rs) is the structure that decides when a cursor is safe to hand the checkpoint writer: it tracks every registered event as a Slot in arrival order, each carrying a remaining count of outstanding matches, and only ever publishes the cursor of the longest contiguous run of fully-done slots starting from the front, in advance_locked (progress.rs). “Fully done” means every match dispatched from that event has either reached its sink or been recorded as a dead letter. There is no third outcome. One still-open slot anywhere in that prefix holds every slot behind it back, even ones that finished long ago (CompletionGuard::complete, progress.rs, exercised directly in middle_event_finishing_last_gates_the_prefix, progress.rs).

Three consequences follow directly from that rule:

  • Checkpoints only ever move forward past finished work. A CompletionGuard dropped without calling complete() (a worker task dying mid-delivery, say) permanently stalls the prefix at that slot rather than letting it expire or time out (progress.rs; see a_dropped_guard_stalls_the_checkpoint, progress.rs). A stall is visible in the status API as in_flight_events; nothing here manufactures a false “done.”
  • Overload is backpressure, not data loss. The channels between source, processor, and each sink worker are bounded and never drop a message on the full side: a slow sink fills its own channel, which stalls the processor’s dispatch loop, which stalls the source’s own send. Nothing downstream of a full queue is asked to discard anything; the entire pipeline just waits.
  • A crash can only repeat work, never skip it. Resuming means reading the last persisted checkpoint and continuing from there. Anything that cursor already covers is gone for good from the source’s perspective, but by the rule above that only happens once every match tied to it is finished, so the only way a crash surfaces to a consumer is by re-delivering an event whose checkpoint write hadn’t landed yet, never by silently dropping one.

Deterministic match IDs are what make that repetition harmless. MatchId::derive hashes the network, monitor, cursor, an index within the event, and a structural walk of the decoded event’s own field tree (crates/blockwatcher-types/src/id.rs): nothing about it depends on wall-clock time, retry count, or which run produced it. Re-processing the same on-chain event after a restart decodes the same bytes at the same slot and derives the exact same id (match_id_re_derivation_from_the_same_content_is_stable, id.rs), which is what lets a consumer treat two deliveries of the same id as one occurrence rather than two.

Happy path

One raw event, one match, one sink, nothing goes wrong:

sequenceDiagram
    participant Source as Source task
    participant Processor as Processor task
    participant Progress as Progress tracker
    participant Sink as Sink worker task
    participant CPW as Checkpoint writer task
    participant Store as checkpoint storage

    Source->>Processor: event at cursor C
    Processor->>Processor: decode + match (1 match)
    Processor->>Progress: begin(C, outstanding: 1)
    Processor->>Sink: dispatch match M
    Sink->>Sink: deliver Match(M) (success)
    Sink->>Progress: guard.complete()
    Progress->>Progress: prefix fully done, watermark = C
    Progress->>CPW: publish checkpoint(C)
    CPW->>Store: persist checkpoint(C)

An event with zero matches is registered too, with zero outstanding: it completes the moment begin runs, which is why an event that matched nothing still lets the cursor advance immediately rather than wait on deliveries that were never dispatched. An event whose monitors all retained or discarded at the gate is registered with zero outstanding the same way.

Crash after partial delivery, then resume

Two matches from one event, going to two different sinks. One delivery lands before the crash; the other is still retrying when the process dies. Because the checkpoint requires both guards, cursor C never got persisted. The last durable checkpoint is still whatever preceded it:

sequenceDiagram
    participant Source as Source task
    participant Processor as Processor task
    participant Progress as Progress tracker
    participant SinkA as Sink worker task A
    participant SinkB as Sink worker task B
    participant Store as checkpoint storage

    Note over Store: persisted checkpoint = C-1
    Source->>Processor: event at cursor C
    Processor->>Progress: begin(C, outstanding: 2)
    Processor->>SinkA: dispatch match M1
    Processor->>SinkB: dispatch match M2
    SinkA->>SinkA: deliver M1 (success)
    SinkA->>Progress: guard.complete() (M1)
    Note over Progress: M2 still outstanding, watermark holds at C-1
    Note over SinkB: process crashes mid-retry of M2
    Note over Store: restart resumes from C-1 (never advanced)

    Source->>Processor: re-emit event at cursor C
    Processor->>Progress: begin(C, outstanding: 2)
    Processor->>SinkA: dispatch match M1 (same MatchId as before)
    Processor->>SinkB: dispatch match M2 (same MatchId as before)
    SinkA->>SinkA: deliver M1 again (duplicate)
    Note over SinkA: consumer recognizes the duplicate by M1's id
    SinkA->>Progress: guard.complete() (M1)
    SinkB->>SinkB: deliver M2 (success this time)
    SinkB->>Progress: guard.complete() (M2)
    Progress->>Progress: prefix fully done, watermark = C
    Progress->>Store: checkpoint(C) persisted

Nothing about which sink had already succeeded survives the crash: the in-memory Progress state is gone, and the only source of truth on restart is the last persisted checkpoint. Re-reading from C-1 means the whole event at C is decoded and matched again, M1 included, which is exactly the duplicate a consumer’s dedup-by-id exists for.

Dead letters

When deliver_with_retry (crates/blockwatcher-core/src/pipeline/delivery.rs) exhausts a sink’s retry budget (every attempt spent on a transient or rate-limited failure, or a permanent/retry-narrower error that skips retry entirely), the sink worker does not drop the event. It builds a DeadLetter carrying the event’s match id, which monitor and sink produced it, the cursor it traces back to, the attempt count, a reason string, and the original SinkEvent payload, and writes it through the network’s storage backend, in dead_letter (crates/blockwatcher-core/src/pipeline/sink_worker.rs). Only after that write durably lands, and, for a Match, only after the delivery journal row is written, does the worker complete its guard, in dead_letter’s success branch (sink_worker.rs); if the write itself fails, the worker retries the write forever on its own doubling backoff rather than completing early, in dead_letter’s retry loop (sink_worker.rs): the same “never advance past an unrecorded loss” rule as everywhere else in this contract, just applied one layer further out. A dead-lettered Match stays in the journal so a later invalidate can still retract it; a dead-lettered Retracted does not.

An operator interacts with a network’s dead-letter queue through these routes, in router (crates/blockwatcher-api/src/routes/networks_ops.rs):

  • GET /networks/{id}/dead-letters pages the queue in insertion order: a pure storage read, in list_dead_letters (crates/blockwatcher-core/src/control/dead_letters.rs).
  • POST /networks/{id}/dead-letters/{match_id}/replay re-runs the same deliver_with_retry path against the sink the letter names, rebuilt fresh from its stored config. Success deletes the letter (204); exhaustion bumps its attempts and reason in place and leaves it queued, in replay_dead_letter (dead_letters.rs). A letter recorded before payload storage was added has no payload to replay and is refused outright rather than silently no-op’d (dead_letters.rs, message NO_REPLAY_PAYLOAD). A letter whose payload is SinkEvent::Retracted is likewise refused (NO_REPLAY_RETRACTED): only match events can be replayed.
  • DELETE /networks/{id}/dead-letters/{match_id} and DELETE /networks/{id}/dead-letters (the latter with optional sink/monitor filters, returning the count discarded) discard letters outright, without attempting delivery, in discard_dead_letter/discard_dead_letters (dead_letters.rs). Replaying is the only route that resends a letter; discarding is permanent.

The sqlite storage backend is what makes a dead-letter queue survive a restart at all; the memory backend does not, matching its own no-persistence contract.

Delivery policies

Everything on this page is configured per sink, on the SinkDef resource (a JSON document written through PUT /sinks/{id} or a seed directory, hot-reloaded like every resource): nothing here is hardcoded or instance-config. One complete sink showing every policy field beside the module’s own config:

{
  "id": "alerts",
  "module": "webhook",
  "config": {
    "url_secret": "env:ALERTS_WEBHOOK_URL",
    "headers": { "x-source": "blockwatcher" },
    "timeout_ms": 10000,
    "body_template": "{% if type == \"digest\" %}{{ matches | length }} matches{% else %}{{ type }}: {{ monitor }}{% endif %}"
  },
  "retry": { "max_attempts": 8, "initial_backoff_ms": 200, "max_backoff_ms": 30000 },
  "throttle": { "max_deliveries": 60, "window_ms": 60000 },
  "aggregate": { "window_ms": 30000, "max_batch": 100 }
}

config belongs to the named module (here webhook; its fields, including the optional body_template renderer, are that module’s own documentation). retry, throttle, and aggregate are the engine’s, identical for every module, each optional: omit retry for the engine-wide default, omit throttle for no throttling, omit aggregate for unbatched delivery. The values shown are each field’s defaults.

A retry whose initial_backoff_ms sits above its own max_backoff_ms is refused by the same validate call throttle and aggregate go through, at the same two points described below for aggregation: put_sink refuses the write, validate_and_build refuses to boot a stored row that fails the same check. DeliveryRetry::backoff_bounds (crates/blockwatcher-types/src/resource.rs) is what every retry loop reads its starting backoff and ceiling from, deliver_with_retry and the sink worker’s journal, dead-letter, and forget retries alike, so the floor against an out-of-range pair lives in one place rather than at each of those call sites.

A sink’s throttle field (SinkDef.throttle: Option<Throttle>, crates/blockwatcher-types/src/resource.rs) caps how often that sink admits successful deliveries, independent of the retry and dead-letter policy above. The fields that define it:

  • max_deliveries: the number of successful Match deliveries the window admits before it starts refusing more, default 60.
  • window_ms: the window’s length in milliseconds, default 60_000.

A zero in either field is refused at write time, the same “validate before constructing anything” discipline every resource write goes through: a sink’s throttle is either absent or fully valid, never partially so.

At runtime, SinkWorker (crates/blockwatcher-core/src/pipeline/sink_worker.rs) holds a fixed admission window per sink, anchored at the first admitted delivery and reset once window_ms has elapsed. The window belongs to one network’s pipeline, not to the sink id globally: a sink named by monitors on several networks gets one independent window per network, each with its own budget, rather than one shared budget across all of them. A Match that arrives inside the current window’s budget is delivered normally; a Match that would exceed it never reaches the sink at all. It is dead-lettered instead, through the same dead_letter path an exhausted retry budget uses, with a reason naming the policy that suppressed it:

permanent: throttled: policy allows 60 deliveries per 60000ms; replay this dead letter to deliver it

The permanent: class prefix is the same one every dead-letter reason carries; nothing about the throttle path is a distinct class of dead letter. A throttled match is exactly as listable and replayable as any other dead letter, through the same GET /networks/{id}/dead-letters and POST /networks/{id}/dead-letters/{match_id}/replay routes described above: replaying one delivers it directly, without re-checking the window it was suppressed under.

Only a delivered Match spends budget. A failed attempt already pays for itself through the ordinary retry-then-dead-letter path and must not also consume quota meant for the next real delivery. Retracted events skip the throttle check entirely, at the same call site that would otherwise apply it: a correctness signal outranks a fatigue policy, so an undo is never the thing a noisy sink drops on the floor.

The window is runtime-only state, held in memory beside the sink worker rather than persisted: a pipeline restart opens a fresh window with zero deliveries counted. An operator relying on the throttle to bound a sustained rate should not expect the count to survive a restart.

Aggregation

A sink’s aggregate field (SinkDef.aggregate: Option<Aggregate>, crates/blockwatcher-types/src/resource.rs) batches several matches into one digest delivery instead of sending each on its own. The fields that define it:

  • window_ms: how long the batch stays open once the first match joins it, default 30_000, capped at 86_400_000 (one day).
  • max_batch: the number of matches that closes the batch immediately, default 100, capped at 10_000.

A zero in either field is refused the same way a zero throttle field is, and so is a value above either cap: the caps bound what one open window can cost, since every match in it is held in memory and the checkpoint stays behind all of them until the window closes. max_batch prices that worst case in matches, window_ms prices it in time. Each route runs a SinkDef’s policy through the same validate call, but they enforce it at different points. put_sink (crates/blockwatcher-core/src/control/writes.rs) refuses to persist a SinkDef whose policy fails validation. validate_and_build (crates/blockwatcher-core/src/engine/boot.rs) runs the identical check against rows already in storage, a restored backup or a hand-edited row among them, and refuses to start the engine rather than refusing a write. A sink’s aggregation policy is either absent or fully valid, never partially so.

At runtime, AggregateBuffer (crates/blockwatcher-core/src/pipeline/aggregate.rs) holds one batch per sink worker, and only a Match ever joins it; the buffer flushes, delivering whatever it holds as a single event, on any of:

  • the batch reaches max_batch;
  • window_ms elapses since the first match joined the batch, not the most recent one, so a steady trickle still reaches the sink once per window instead of waiting forever for a batch that never fills;
  • the sink worker’s channel closes, which is what a cooperative drain looks like from this worker’s side: a partial batch still in progress is flushed rather than dropped;
  • a Retracted event arrives. The buffer flushes first and the retract is delivered after, so a retract can never overtake the match it retracts: per-sink delivery order is the only evidence a sink has of which of the two came last. Retracted events are never buffered themselves and never count toward max_batch, the same correctness-outranks-fatigue rule that exempts them from the throttle above.

A flush of exactly one match delivers a plain SinkEvent::Match: a quiet sink whose matches never overlap inside one window sees the identical wire format it would see with no aggregate field set at all. A flush of two or more delivers a single SinkEvent::Digest { matches }, rendered by canonical_body as:

{
  "type": "digest",
  "matches": [
    { "id": "…", "monitor": "usdc-sepolia-transfers", "network": "sepolia", "event": { } },
    { "id": "…", "monitor": "usdc-sepolia-transfers", "network": "sepolia", "event": { } }
  ]
}

Every buffered match holds its CompletionGuard uncompleted for as long as it sits in the batch, exactly as an in-flight delivery does. A buffered window stalls the checkpoint by design: the batch’s oldest match cannot pass the checkpoint until the whole batch it ends up part of is delivered or dead-lettered, even though nothing has been sent to the sink yet. A hard-cancel or crash drops those guards uncompleted rather than flushing them, the same safe-by-default direction a mid-delivery crash already takes elsewhere in this contract: on replay, the source re-emits the events those matches came from, they are re-matched, and re-buffered, and eventually re-delivered, deduplicated by MatchId exactly as any other redelivered match is. There is no persisted window state to resume from; the window itself is runtime-only, the same stance the throttle above takes.

On a successful digest delivery, every contained match is journaled at its own cursor, in arrival order, before any guard completes, the same journal-then-complete ordering a lone match’s delivery follows. On retry exhaustion, the digest does not dead-letter as one row: each contained match is dead-lettered individually, carrying the digest’s own failure reason, one match at a time and in arrival order. Per match, the record order is the same as a lone dead-lettered match’s: the dead-letter row is written first, then that match is journaled, then the next match in the digest follows the same two steps. This is the replay caveat to carry forward: a dead-lettered digest member replays as a single match, never as the digest it originally arrived in, because POST /networks/{id}/dead-letters/{match_id}/replay only ever knows how to resend one match at a time.

The delivery policies compose rather than interact specially: a digest counts as exactly one delivery against the sink’s throttle window, the same one unit a lone match would have spent, so aggregating a burst into fewer deliveries is one way an operator keeps a bursty sink under a tight throttle budget. A digest the throttle refuses is suppressed exactly like a suppressed lone match would be, except it dead-letters every contained match individually rather than one row, matching how a delivered digest is journaled.

Monitor gates versus sink policies

throttle and aggregate are fields on SinkDef. A gate is a field on Monitor. They are not interchangeable:

WantUseDo not use
Fire only when N predicate-true hits span ≤ W (event time); then start a new sessionMonitor gate.module = thresholdSink aggregate (holds the checkpoint until the window closes; batches Matches that already exist)
At most one alert per event-time window; later hits vanishMonitor gate.module = max_onceSink throttle (caps deliveries after a Match exists; refusals are dead letters you can replay)
Drop this event because it is odd / before noon UTCpredicate (block.number % 2, block.timestamp % 86400)A gate. Per-event filters are not stateful.
Rate-limit a noisy webhook without losing matchesSink throttlemax_once (those hits are gone, not replayable)
Batch already-fired matches into fewer HTTP postsSink aggregatethreshold (threshold decides whether a Match exists)
  • threshold gate: N predicate-true hits whose block.timestamp values span ≤ window_ms mint one digest of those hits, then reset the session. Held hits persist in gate_hits and complete with outstanding 0, so a 60-minute threshold does not stall the checkpoint for 60 minutes.
  • aggregate sink: already-minted Matches sit in memory and the checkpoint stays behind all of them until the window flushes. That is why a long aggregate window is operationally expensive.
  • max_once gate: at most one alert per event-time window; later hits are discarded with no dead letter.
  • throttle sink: later Matches are dead-lettered and remain replayable. Only successful deliveries spend budget. Restarts reset the in-memory window.

Full gate contract: Gates.

The evm-mempool exception

Everything above assumes a source whose cursor is a real position in a feed it can resume from. evm-mempool is the one shipped source where that assumption fails: its cursor is a per-run arrival counter, not a chain position, so its checkpoint is only a dedupe watermark for the current process’s lifetime: a restart cannot replay whatever was pending in the node’s mempool while the process was down, and that work is simply gone. The same instability reaches into identity: because the counter restarts from zero-relative-to-the-fresh-checkpoint on every process start, the identical pending call seen again after a gap is minted a different arrival number and therefore a different MatchId. Consumers of this source deduplicate on the transaction’s own hash, not on the match id. Selectors § The position problem covers the mechanics in full.

What a sink receives: SinkEvent

Sink::deliver takes a SinkEvent, not a bare match (crates/blockwatcher-ports/src/sink.rs):

#![allow(unused)]
fn main() {
pub enum SinkEvent {
    Match(Match),
    Retracted { match_id: MatchId },
    Digest { matches: Vec<Match> },
}
}

(crates/blockwatcher-types/src/event.rs)

That set is closed: core reasons about every variant exhaustively, the same way it reasons about the canonical value model. Digest is what aggregation delivers in place of several separate Match events; it only ever reaches a sink whose SinkDef sets aggregate. A sink module that only handles Match will silently keep work a later Retracted asked it to undo, or drop every match a later Digest bundled together.

Every variant travels the same deliver_with_retry path (crates/blockwatcher-core/src/pipeline/delivery.rs): the same retry budget, the same backoff, the same dead-letter write when the budget runs out, and the same CompletionGuard (one per contained match, for a Digest). A failed undo can stall the checkpoint or land in the dead-letter queue; it is never dropped on the floor. Consumers must treat a redelivered Retracted as idempotent on match_id, just as they already treat a redelivered Match as idempotent on Match.id, and a redelivered Digest’s contents as idempotent per contained Match.id for the same reason.

Built-in sinks (webhook, script, log) render the event as tagged JSON through one helper, canonical_body (crates/blockwatcher-sinks/src/lib.rs):

{ "type": "match", "id": "…", "monitor": "…", "network": "…", "event": { } }
{ "type": "retracted", "match_id": "…" }
{
  "type": "digest",
  "matches": [
    { "id": "…", "monitor": "…", "network": "…", "event": { } },
    { "id": "…", "monitor": "…", "network": "…", "event": { } }
  ]
}

That tagging is a wire break against a bare Match object. A DeadLetter’s payload is Option<SinkEvent>: a row written before this shape existed, or written as a legacy Match object, still loads as SinkEvent::Match. Replay through the API only accepts a Match payload; a retraction payload is refused rather than re-sent (NO_REPLAY_RETRACTED, crates/blockwatcher-core/src/control/dead_letters.rs).

Shallow vs deep invalidation

A source whose cursor is a real chain position can see its feed fork. Whether sinks hear about that fork depends on which side of the confirmation barrier moved:

──── already emitted / checkpointed ────|──── unconfirmed window ────| head
                                        ^ confirmations barrier
  • Shallow. Only the unconfirmed side moves. Those cursors were not durably offered to sinks (or a mid-scan race is retried in place, inside the source). No Retracted leaves the module.
  • Deep. The fork cuts into work already emitted. Restarting the source alone would offer the new fork as new Matches (often with new MatchIds) and leave the consumer holding the orphan with no undo signal.

Core never names a chain, a reorg, or a confirmation depth. A source that has positively detected a deep invalidate returns SourceOutcome::Invalidated { from } from Source::run (crates/blockwatcher-ports/src/source.rs): a typed non-success, not a SourceError. The from cursor is the proven point; everything with cursor > from previously implied by this source is on a dead fork. A source whose entire tracked history is refuted (no tracked ancestor matches the live chain at all) proves divergence only at or below the oldest height it can still vouch for, bounded by the tracker’s own depth rather than any named fork block; when that depth reaches back to block 0, the bound is genesis, but that is the tracker’s own limit speaking, not a claim the fork itself sits there. The EVM RPC source is what translates a break against already-emitted work into Invalidated at the module boundary: a walk that finds a still-matching ancestor invalidates from that proven fork (beyond_confirmations); a walk that finds none invalidates from just below the oldest tracked height (beyond_window); journaled deliveries above it are retracted the same as any other invalidate (the journal-depth caveat below still applies), and the source restarts from the rewound checkpoint. Both are the same on a mid-run break as on resume. Mempool and other sources that have no finality never produce it.

The engine supervisor handles Invalidated as a control path, not a crash (crates/blockwatcher-core/src/engine/invalidate.rs):

  1. Cooperative cancel and drain of that network’s pipeline (the same deadline drain any restart uses; no abort on this path unless the deadline is missed).
  2. Prune gate_hits for this pipeline where cursor > from. Do not delete rows with cursor ≤ from.
  3. list_deliveries_after(network, from) against the bounded delivery journal.
  4. One-shot retract pass: a SinkEvent::Retracted { match_id } for each retained id, through the same deliver_with_retry live traffic uses.
  5. Wait until every retract is delivered or dead-lettered. An unrecovered retract leaves the checkpoint unrewound and does not restart.
  6. Rewind the checkpoint to from, unless no write could move the cursor strictly backward: from is not behind the stored checkpoint, no checkpoint is stored at all (writing one would fabricate delivery progress), or the post-drain checkpoint read failed. A rewind must move the cursor backward or not at all, so in each of those cases the write is refused instead and counted as blockwatcher_rewinds_refused_total.
  7. Re-read the persisted checkpoint, then restart the source so replacement Matches can flow.

In-flight work on the old pipeline is dropped with it: it was never checkpointed, so it is not a silent skip. For a given sink, every Retracted from an invalidate finishes before any post-restart Match from that network is offered. Retracts go to the sinks that network’s pipeline was spawned with. After the network resource is deleted, those ids live on a runtime-only tombstone so a late Invalidated still notifies only those sinks, not every other network’s consumers. Sibling networks are not touched. One network’s invalidate does not abort the others.

sequenceDiagram
    participant Src as Source
    participant Sup as Supervisor
    participant Drain as Pipeline drain
    participant Journal as Delivery journal
    participant Sink as Sink worker
    participant Store as Checkpoint

    Src->>Sup: Invalidated { from }
    Sup->>Drain: cooperative cancel + drain
    Drain-->>Sup: outgoing work finished
    Sup->>Journal: list_deliveries_after(from)
    Journal-->>Sup: retained (cursor, match_id) rows
    loop each retained id, per sink
        Sup->>Sink: Retracted { match_id }
        Sink->>Sink: deliver_with_retry
        Note over Sink: success forgets the row,<br/>dead-letter keeps it
    end
    Note over Sink: all retracts delivered or dead-lettered
    Sup->>Store: rewind checkpoint to from
    Sup->>Src: restart (replacement Matches)

The delivery journal

A Match that is delivered or dead-lettered is recorded in a per-network journal before its completion guard completes (journal_delivery, crates/blockwatcher-core/src/pipeline/sink_worker.rs). That ordering is load-bearing: completing first would let the checkpoint pass a match the journal never durably saw, so a later invalidate could not retract it. Retracted is not journaled as a positive fact; a successful retract forgets the row. A retract that exhausts to a dead letter keeps the journal row so a later invalidate can re-offer it.

The journal is bounded by instance config journal_depth (default 1024, measured in cursor primary units: block number on EVM). On each record_delivery, rows whose cursor.primary is strictly behind recorded.primary.saturating_sub(journal_depth) are dropped as part of the same write. There is no operator prune API and no “disable journal” switch in this version; the depth is meant to sit well above typical confirmation windows (12–64).

When an invalidate’s from plus journal_depth does not cover the checkpoint high-water mark, rewind and restart still happen, but only ids still retained are retracted. The gap is counted (blockwatcher_journal_gap_total) and logged at error, naming the network, the invalidate cursor, the checkpoint, and the configured depth, never silent. See Observability and Troubleshooting. Deliveries at or below the invalidate’s own from bound are never retracted even when the true fork lies deeper than the source could prove: block from.primary itself is re-emitted in full on restart, so any already-delivered match inside it arrives again as a byte-identical duplicate under the at-least-once contract, not a gap.

A gate’s Emit carries the same durability shape one layer earlier: it rides an engine-owned outbox row per sink, written in the same storage transaction that consumes the hits its journal is built from, and held until that sink’s delivery outcome is itself durable. A crash between those two points loses nothing: the row survives to be drained and redelivered, at the cost of a possible duplicate, never a gap, the same trade this page’s dead-letter and journal contracts already make. Downstream consumers dedupe on Match.id, deterministic for the same reason a redelivered plain match already is. See Gates for the emission-to-outcome path in full.

Delivery scenarios

Delivery guarantees is the field-by-field reference: what each policy’s config keys do, what a Progress slot tracks, exactly which route replays a dead letter. This page is its companion for a different question: given a monitor, a sink, and whatever policies are configured on it, what actually happens, end to end, in the situation an operator is looking at right now? Every diagram here traces a real code path already described on that page; nothing here is a new mechanism, only a new view of the ones that exist.

Picking a scenario

SituationJump to
Nothing has gone wrong yet: what does normal delivery look like?The full path, one picture
A delivery attempt failed: what happens before it’s given up on?Retry and backoff
A sink is receiving more matches than it should have to handleThrottle: admission and suppression
Several matches should arrive as one message, not one eachAggregation: hold and flush
I want an alert only after N Transfers in an hour of block time, without stalling the checkpointThreshold gate, not aggregate
Two policies are configured on the same sink at onceComposing policies
The chain forked, or might haveReorg and invalidation
“Will I ever get a duplicate? Will I ever miss one?”What the guarantee actually is, by scenario
“What should I set for my use case?”Configuration cookbook

The full path, one picture

Every match takes the same route from decode to checkpoint, whether or not any policy below is configured. Policies insert themselves as named branch points on this one path; they never create a second path:

flowchart TD
    A[Event decoded, matched] --> B["Progress::begin: guard created,<br/>cursor stalls until it completes"]
    B --> C{Sink has<br/>throttle?}
    C -->|admitted| D{Sink has<br/>aggregate?}
    C -->|refused| DL1["Dead letter<br/>(reason: throttled)"]
    D -->|no| E[Deliver attempt]
    D -->|yes| F["Buffer holds the match<br/>(guard stays uncompleted)"]
    F -->|flush trigger fires| G{Flush held<br/>one match or many?}
    G -->|one| E
    G -->|many| H[Deliver as one Digest]
    E --> I{Attempt<br/>result?}
    H --> I
    I -->|success| J[Journal the delivery]
    I -->|exhausted retries| K["Dead letter<br/>(per contained match, if a digest)"]
    J --> L["guard.complete()"]
    K --> L
    DL1 --> L
    L --> M["Progress advances the checkpoint<br/>once every guard in the prefix is done"]

Two things this picture makes visible that are easy to miss reading the policies one at a time: every exit except the throttle refusal passes through a delivery attempt first (aggregation defers when an attempt happens; it never skips one), and every exit reaches guard.complete(). There is no branch that drops a match without either delivering it, dead-lettering it, or leaving its guard open (which stalls the checkpoint rather than silently passing it). See The contract, precisely for why that third option doesn’t exist.

Retry and backoff

One delivery attempt, from dispatch to either success or exhaustion. This is the branch under “Deliver attempt” in the picture above:

flowchart TD
    A[Attempt delivery] --> B{Result?}
    B -->|success| Z[Journal + complete guard]
    B -->|classified error| C{Class?}
    C -->|Permanent| D[Skip remaining attempts]
    C -->|Transient / RateLimited| E{Attempts<br/>remaining?}
    C -->|RetryNarrower| E
    E -->|yes| F[Wait backoff, doubling<br/>from initial_backoff_ms<br/>up to max_backoff_ms]
    F --> A
    E -->|no| D
    D --> G["Dead letter<br/>(reason names the class)"]

A Permanent error (a config the sink will never accept, a 4xx a retry can’t fix) skips straight to dead-lettering rather than spending the rest of the attempt budget on a result that cannot change; see Dead letters for the exact field a dead letter carries and the API routes (GET .../dead-letters, POST .../dead-letters/{id}/replay, and the DELETE routes that discard one or many) that read, replay, or discard it. retry’s fields (max_attempts, initial_backoff_ms, max_backoff_ms) are what this diagram’s boxes read from; omitting retry on a SinkDef uses the engine-wide default from blockwatcher.toml, not “no retry at all.”

Throttle: admission and suppression

The branch under “Sink has throttle?” above. A window opens on the first admitted delivery and holds for window_ms:

flowchart TD
    A[Match arrives at the sink worker] --> B{Window still open,<br/>and budget left?}
    B -->|no window open yet, or window_ms elapsed| C[Open a fresh window]
    C --> D[Admit, spend one unit]
    B -->|open and budget remains| D
    D --> E[Deliver normally]
    B -->|open and budget exhausted| F["Dead letter<br/>(reason: throttled, attempts: 0)"]

Only a delivered match spends budget: a failed attempt already paid for itself through the retry path above and must not also cost throttle quota meant for the next real delivery. A throttled match is not silently lost: it’s dead-lettered through the exact same path an exhausted retry uses, so it’s listed and replayable identically (replaying delivers it directly, without re-checking the window it was suppressed under). See Delivery policies for the two config fields and their defaults.

Aggregation: hold and flush

The branch under “Sink has aggregate?” above. Unlike throttle, a held match is not resolved yet: it sits with its guard open until one of four things flushes the buffer:

flowchart TD
    A[Match arrives, sink has aggregate] --> B[Add to the open buffer]
    B --> C{Which flush trigger<br/>fires first?}
    C -->|buffer reaches max_batch| D[Flush now]
    C -->|window_ms since the FIRST<br/>held match elapses| D
    C -->|sink worker's channel closes<br/>= a cooperative drain| D
    C -->|a Retracted event arrives<br/>for this sink| E[Flush the buffer,<br/>THEN deliver the Retracted]
    D --> F{One match held,<br/>or several?}
    F -->|one| G[Deliver as a plain Match]
    F -->|several| H[Deliver as one Digest]

The window is anchored to the first match that joined it, not the most recent: a steady trickle of matches still reaches the sink once per window rather than waiting forever for a batch that never fills. A Retracted is never buffered and never counts toward max_batch: it flushes whatever is already held first, so a retract can never overtake the match it retracts (per-sink delivery order is a consumer’s only evidence of which came last). See Aggregation for the digest wire shape, the exhaustion-dead-letters-each-match-individually behavior, and why a dead-lettered digest member replays as a single match, never as the digest it arrived in.

The one behavior this diagram can’t show, because it’s an absence, not a branch: a hard-cancel or crash while matches are held drops those guards uncompleted rather than flushing them. Nothing here reads that as “lost”: the checkpoint simply doesn’t advance past them, and on restart the source re-emits the events those matches came from, which re-match, re-buffer, and eventually re-deliver, deduplicated by MatchId like any other redelivery. There’s no persisted window to resume from; the window is runtime-only, same as throttle’s.

Threshold gate, not aggregate

The branch sits before Progress::begin for a match. Three hits spanning ≤ window_ms produce one digest and complete outstanding for that emit; two hits complete with outstanding 0 and persist holds. Contrast the aggregate diagram on this page: every buffered match there holds a guard open. Full contract: Gates.

Composing policies

The two flow diagrams above never run in isolation on a sink that configures both. Four compositions are worth naming because each answers a question an operator will actually ask:

  • A digest costs one throttle unit, not one per contained match. Aggregating a bursty sink into fewer, larger deliveries is a direct way to keep it under a tight throttle budget: ten matches held into one digest spend the same one unit of window budget a single match would.
  • A throttled digest still dead-letters every contained match individually. The throttle refusal happens at the delivery-attempt boundary, after aggregation has already flushed a Digest; suppressing it doesn’t collapse it back into one dead-letter row, since replay still needs to resend each match on its own.
  • Retract bypasses both policies, at the same call site each time. A Retracted skips the throttle check entirely and flushes (rather than joins) an aggregate buffer. This is deliberate in both cases: a correctness signal (undoing a match a consumer may have already acted on) outranks a fatigue policy meant for noisy matches, so an undo is never the thing a loud sink drops on the floor.
  • A reorg’s retract pass ignores both policies too, for the same reason: see the sequence diagram in the next section. The retracts an invalidate sends ride the same deliver_with_retry path live traffic does, but they are never throttled or buffered; only the ordinary retry budget applies to them.

Reorg and invalidation

Whether a fork produces a Retracted at all depends on which side of the confirmation barrier it cuts into. This is a three-way branch, not two:

flowchart TD
    A[Source detects its tracked chain<br/>no longer matches the live head] --> B{Where does the<br/>fork land?}
    B -->|only in the unconfirmed window,<br/>never durably offered| C["Shallow: retried in place<br/>inside the source. No Retracted."]
    B -->|cuts into already-emitted work,<br/>a tracked ancestor still matches| D["Deep, proven fork:<br/>Invalidated { from: fork point }"]
    B -->|cuts deeper than the tracker<br/>can prove: no tracked ancestor matches| E["Deep, unproven depth:<br/>Invalidated { from: just below<br/>oldest tracked height }"]
    D --> F[Engine invalidate sequence]
    E --> F

The tracker’s own memory bounds how deep a fork can be proven: it keeps clamp(2 × confirmations, 8, 64) entries, so a reorg deeper than 64 blocks is a real limit of the design, not an oversight; the source still invalidates, just from the deepest point it can still vouch for rather than the true fork block. Both deep branches converge on the same sequence once Invalidated { from } is raised: this is the existing diagram from Delivery guarantees, repeated here because it’s the scenario this page’s readers are most often looking for:

sequenceDiagram
    participant Src as Source
    participant Sup as Supervisor
    participant Drain as Pipeline drain
    participant Journal as Delivery journal
    participant Sink as Sink worker
    participant Store as Checkpoint

    Src->>Sup: Invalidated { from }
    Sup->>Drain: cooperative cancel + drain
    Drain-->>Sup: outgoing work finished
    Sup->>Journal: list_deliveries_after(from)
    Journal-->>Sup: retained (cursor, match_id) rows
    loop each retained id, per sink
        Sup->>Sink: Retracted { match_id }
        Sink->>Sink: deliver_with_retry
        Note over Sink: success forgets the row,<br/>dead-letter keeps it
    end
    Note over Sink: all retracts delivered or dead-lettered
    Sup->>Store: rewind checkpoint to from
    Sup->>Src: restart (replacement Matches)

One gap worth knowing about explicitly: if from plus journal_depth doesn’t reach back far enough to cover the checkpoint’s high-water mark, the rewind and restart still happen, but only the ids the journal actually retained get a Retracted. That gap is never silent: it’s counted (blockwatcher_journal_gap_total) and logged naming the network, the invalidate cursor, the checkpoint, and the configured depth. See The delivery journal.

evm-mempool never produces Invalidated at all: its cursor has no chain position to fork against. See The evm-mempool exception.

What the guarantee actually is, by scenario

“At-least-once” is the contract everywhere, but what that means in practice (duplicates, gaps, and whether a consumer hears about it) differs by scenario:

ScenarioDuplicates possible?Gaps possible?Consumer notified?Operator action
Happy pathNoNoNoNone
Crash before checkpoint persistsYes (dedupe by MatchId)NoNoNone
Retry exhaustedNoNoYes (dead letter)Inspect and replay, or accept the loss
Throttle suppresses a matchNoNo (it’s a dead letter, not dropped)Yes (dead letter)Replay, or widen the window/budget
Aggregate buffer, clean flushNoNoNo (delivered as one Digest)None
Aggregate buffer, hard-cancel/crashYes (re-buffered, re-delivered)NoNoNone
Shallow reorgNoNoNo (retried in place)None
Deep reorg, proven forkYes (blocks at/below from re-emit)NoYes (Retracted per retained id)None, unless replay is desired
Deep reorg, unproven depth (>64 blocks)YesOnly for ids the journal no longer retainsPartial (only retained ids get Retracted)Check blockwatcher_journal_gap_total; treat un-retracted matches as suspect
evm-mempool restartNo (arrival counter resets)Yes (whatever was pending is simply gone)NoNone available; this source has no resume guarantee by design

“Gaps possible” only ever appears where the journal’s bounded depth couldn’t reach far enough, or on the one source that never promised a resumable position in the first place. Every other row’s answer is “no” because of the same one rule: a checkpoint only advances once every match tied to it has either reached its sink or become a durably recorded dead letter, so anything a crash or a restart repeats was, by construction, never durably finished.

Configuration cookbook

Named starting points, not the only correct answer for every case: each maps a goal to the fields that get you there, with the reasoning so you can move off the suggested numbers with the right things in mind.

“I don’t want duplicate delivery noise from one burst of activity.” Set aggregate with a window_ms wide enough to catch a typical burst and a max_batch above your usual burst size, so bursts land as one digest instead of many separate matches — HTTP batching of matches that already fired. Widening window_ms trades delivery latency for fewer messages; a buffered match holds the checkpoint for the whole window, so an unreasonably wide window (the cap is one day, 86_400_000 ms) is a real cost, not a free knob. If the burst should not fire at all until N predicate-true hits, that is the threshold-gate recipe below, not sink aggregate.

“Notify only if Transfer occurred more than 3 times in the last 60 minutes of block time, then start counting again.” Set the monitor’s gate to threshold with count: 3 and window_ms: 3600000. Do not use sink aggregate for this: aggregate would mint a Match per Transfer and stall the checkpoint for the whole window.

“At most one webhook per hour of block time; I do not want the rest queued.” Set gate to max_once. Do not use throttle if you intend those hits to vanish; throttle dead-letters them for replay.

“I want to bound how often a flaky or rate-limited downstream gets hit, without losing any match.” Set throttle to the rate your downstream can actually sustain. Nothing is lost: everything the window refuses becomes a replayable dead letter, so you’re trading “deliver immediately” for “deliver on demand, in bulk, via POST /dead-letters/{id}/replay” rather than trading away completeness.

“I need to know the alert reached the sink even through a real chain reorg, not just that it was attempted once.” This is a confirmations and journal_depth question, not a sink-policy one. confirmations (default 12) is a latency-for-safety trade: a deeper value makes an already-emitted match’s retraction by a reorg less likely, at the cost of how quickly it reaches the sink at all. Because the tracker that proves a fork’s exact depth caps at 64 blocks regardless of confirmations, a journal_depth (default 1024, measured in cursor-primary units: block number on EVM) that comfortably exceeds your confirmations value is what makes a proven-fork retract actually reach every affected match; a shallower journal risks the “unproven depth” row above.

“This monitor watches pending transactions and I need to understand what I’m giving up.” evm-mempool has no resumable cursor and no reorg signal at all: a restart loses whatever was pending, and identity is only stable within one process’s lifetime, so consumers must deduplicate on the transaction hash rather than MatchId. If your use case needs delivery guarantees, watch the confirmed chain with evm-rpc instead; evm-mempool is for latency-sensitive, best-effort visibility into what hasn’t landed yet, not for anything that must not be missed. See The evm-mempool exception.

“I want every dropped or suppressed event to show up somewhere I can alert on.” Nothing here is ever silently dropped, and that’s true without any config, but the observable surface differs by scenario: throttle and retry-exhaustion both produce dead letters (poll GET /networks/{id}/dead-letters or alert on its growth); a deep reorg’s retracts are visible in the Retracted events your sinks receive; a journal gap is a named metric (blockwatcher_journal_gap_total). See Observability for the full metric list.

Chain-agnosticism

Every other concept page so far describes blockwatcher’s engine in terms that never mention a specific chain: cursors, matches, selectors, checkpoints. That is not a writing choice; it reflects an actual boundary drawn in the dependency graph and enforced by tooling, not by convention. This page covers the two halves of that boundary: the shape everything normalizes into on the way past a decoder, and the mechanism that keeps a chain SDK from ever crossing into the crates that don’t need one.

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,sources,sinks,storage,api,metrics,engine,matcher,gate dim
class decoder 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

  • Every decoder must normalize chain-specific data into one canonical Value enum; nothing past the decoder (a matcher, a gate, a sink) ever needs to know which chain produced it.
  • Int/Uint are arbitrary-precision and Address is canonical bytes, so no chain’s values lose precision or collide with another chain’s.
  • blockwatcher-core depends only on the port trait signatures (Source, Decoder, Matcher, Gate, Sink, Storage); it never imports a chain SDK, RPC client, or storage driver directly.
  • check-dep-graph.sh enforces that boundary two ways: a per-crate allowlist of direct dependencies, and a transitive-tree denylist of chain/infra crate families.
  • A new chain family arrives as a module implementing Decoder and Source; every existing predicate, matcher, and sink works against it unmodified.

One value model, every chain

crates/blockwatcher-types/src/value.rs defines the enum that a decoder is required to normalize into and that nothing past it (a matcher, a gate, a sink) ever has to unlearn:

#![allow(unused)]
fn main() {
pub enum Value {
    Null,
    Bool(bool),
    Int(BigInt),
    Uint(BigUint),
    Bytes(Vec<u8>),
    Address(Vec<u8>),
    Str(String),
    Array(Vec<Value>),
    Map(IndexMap<String, Value>),
}
}

(crates/blockwatcher-types/src/value.rs:30-48). Two design choices here carry the whole point of this page:

  • Int and Uint hold arbitrary-precision integers, not a fixed-width native type. A 256-bit token amount off an EVM chain has nowhere to go in a JSON number or an i64 without losing bits; a BigInt/BigUint has no such ceiling, so the value that leaves a decoder is bit-for-bit the value a sink eventually serializes, all the way out to the wire as a decimal string rather than a JSON number, per Value’s own doc comment and the decimal module’s serialization rules (value.rs). This is also why match ids hash Value trees directly, in MatchId::derive’s eat_value step (crates/blockwatcher-types/src/id.rs) rather than some chain-specific encoding of them: one representation, used identically by every consumer of a decoded event.
  • Everything else a chain family might invent still has to land in one of the shapes above. Address is canonical bytes rather than a chain’s native address type, and the decoder that produced those bytes is on the hook for keeping distinct address flavors from ever comparing equal: the doc comment on Value::Address calls out Stellar account versus contract addresses as the concrete case where two genuinely different values are the same byte width and would collide unless the decoder tags them apart (value.rs). The same discipline applies to Map keys: they are always String, so a chain whose native map keys aren’t strings needs an injective rendering rule from the decoder, because two distinct native keys landing on the same canonical string would make a predicate silently address the wrong field (value.rs). Canonicalization is squarely the decoder’s job: the model itself has no chain awareness to lean on.

block.timestamp is that same decoder vocabulary: unix seconds at that path on the canonical tree. A Stellar or Solana decoder maps close_time / blockTime onto it. A chain that cannot expose unix time cannot use time-window gates; the write is 422. The path is not an EVM-only leak.

Value::from_json (value.rs) is the one general-purpose constructor in this file, and it demonstrates part of the same rule from the other direction: JSON numbers that fit in u64/i64 become Uint/Int, and JSON null becomes Value::Null rather than being dropped or coerced into an empty string. A JSON number can’t carry a 256-bit token amount in the first place, so a decoder never gets there through from_json at all: it builds Value::Uint/Int directly from the chain’s own encoding (hex, decimal string, whatever the RPC returns) and reserves from_json for plain JSON-RPC payloads whose numbers already fit a native width. Numbers outside that range or non-integers passed to from_json fall back to Value::Str holding the literal text, so even that path never rounds through a lossy f64.

Core must not know chains

The value model is the shape of the boundary; the rule that nothing past the decoder needs to know which chain produced a value is the reason the boundary exists. blockwatcher states that rule as trait definitions (Source, Decoder, Matcher, Gate, Sink, Storage), all living in blockwatcher-ports, and blockwatcher-core, the crate that actually runs pipelines, tracks checkpoints, and serves the control API, depends on nothing but those trait signatures. It never imports an RPC client, a chain SDK, an ABI library, or a storage driver directly; every one of those lives one layer out, in a module crate that implements one port. crates/blockwatcher-evm is where an EVM-specific type ever appears in this codebase at all: nothing named alloy or ethers is reachable from blockwatcher-core’s own dependency list.

That separation is what a chain-agnostic decoder boundary actually buys: a second chain family arrives as a new crate implementing Decoder and Source, translating its own wire format into the same Value tree above, and every existing predicate, matcher, gate, and sink works against it unmodified, because none of them were ever written against the first chain’s types to begin with.

The direct edge a chain SDK would need to reach the core ring simply does not exist; the only path in is through a module’s decoder, onto the canonical value tree:

flowchart LR
    sdk["chain sdk<br/>e.g. alloy"] --> mod["module crate<br/>blockwatcher-evm"]
    mod -->|"decode: normalize"| val["value tree<br/>blockwatcher-types"]
    val --> core["core ring<br/>blockwatcher-core, blockwatcher-ports, blockwatcher-api"]
    mod -.->|"forbidden edge,<br/>blocked by check-dep-graph.sh"| core

The CI gate that proves it

Stating a rule in a crate’s Cargo.toml dependency list is easy to state and easy to quietly violate one dependency at a time. scripts/check-dep-graph.sh exists because “blockwatcher-core has no chain code” is a claim worth re-checking on every commit rather than trusting once at review time, and it checks it with two independent mechanisms layered on top of each other rather than one.

The first mechanism is a per-crate allowlist of direct dependencies: every workspace crate has a hand-maintained list of exactly what it may declare in [dependencies], and anything a crate’s manifest names that isn’t on its own list fails the check outright (allowlist_for and check_direct in check-dep-graph.sh). blockwatcher-core’s list, for instance, names blockwatcher-types, blockwatcher-ports, serde, tokio, tracing, and a small handful more: no RPC crate, no storage driver, nothing chain-specific (ALLOW_BLOCKWATCHER_CORE in check-dep-graph.sh). Because the list is explicit rather than inferred, adding any new direct dependency to a core crate is a decision someone has to make in the script itself, not a side effect of running cargo add.

The second mechanism runs over the whole transitive dependency tree (cargo tree, not just the manifest) and checks it against a denylist of family-name prefixes that are never allowed to appear anywhere underneath a core crate: alloy, reqwest, sqlx, rusqlite, several chain SDKs, and more (CHAIN_AND_INFRA and check_transitive in check-dep-graph.sh). This is the check that catches what the allowlist alone cannot: a dependency the manifest never names directly, pulled in indirectly by something the allowlist did approve. A handful of module and binary crates that legitimately need one of these families get a narrow, per-crate, per-family exemption from this second net only: blockwatcher-evm is allowed to carry alloy because it is an EVM module and that is its entire purpose, but every family it wasn’t exempted for still applies in full, and the first net (the direct-dependency allowlist) is never relaxed by an exemption at all.

Running both nets together is deliberate, not redundant. Blocking specific names is inherently reactive: a maintainer has to have heard of a chain SDK before they can add it to a denylist, so anything released after the list was last updated walks straight through. Requiring explicit approval flips that around: a crate is rejected by default the moment it shows up unlisted, regardless of whether anyone had ever heard of it, chain-related or not. Layering the transitive scan on top closes the one gap an allowlist alone leaves: a dependency an approved crate quietly starts pulling in one level down, which a scan of direct manifests never sees. Together they mean a core crate cannot gain a chain (or storage, or HTTP) dependency by any route without the gate failing the build, whether that route is a manifest edit or a transitive version bump three crates away.

Modules and trade-offs

Every other concept page treats one specific behavior as swappable: a source in The pipeline, a matcher in Predicates. This page steps back and treats “swappable” itself as the organizing idea: what a module actually is, how one gets into a running binary, and what it means for an operator that every axis of behavior in blockwatcher is one.

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,api,metrics,engine dim
class sources,decoder,matcher,gate,sinks,storage 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

  • blockwatcher has exactly six ports (Source, Decoder, Matcher, Gate, Sink, Storage), and a module is one concrete implementation of exactly one of them.
  • A module never straddles two ports, even when several modules live in the same crate, and configuration resolves a name against the running binary’s own module catalog.
  • Two modules behind the same port can trade off completeness against something else, most visibly evm-rpc (at-least-once delivery, higher latency) against evm-mempool (pending visibility, no restart recovery).
  • Switching modules is the operator’s actual lever for a trade-off: changing one module leaves every other port’s behavior untouched, since none of them are written against one module’s assumptions.

Everything an operator composes is a module

An blockwatcher deployment has exactly six axes along which behavior can differ: how it reads a chain, how it turns raw bytes into blockwatcher’s canonical shape, how it decides whether a decoded occurrence counts, when a predicate-true hit becomes a delivery, where a match goes, and where resources and operational state persist. Those six axes are the six ports blockwatcher defines (Source, Decoder, Matcher, Gate, Sink, Storage), and a module is, by definition, one concrete implementation of exactly one of them. Nothing in blockwatcher is configured by writing code against a bespoke integration point; every one of these six choices is made the same way, by naming a module in configuration and giving it whatever config object that module expects.

The six ports and every module registered behind each one, in this build, look like this:

flowchart TD
    source["Source"] --> rpc["evm-rpc"]
    source --> mempool["evm-mempool"]
    decoder["Decoder"] --> evm["evm"]
    matcher["Matcher"] --> expr["expr"]
    gate["Gate"] --> threshold["threshold"]
    gate --> maxonce["max_once"]
    sink["Sink"] --> webhook["webhook"]
    sink --> script["script"]
    sink --> log["log"]
    storage["Storage"] --> memory["memory"]
    storage --> sqlite["sqlite"]

    %% Layout only, no meaning: these invisible edges wrap the six ports
    %% into bands. Without them every port sits on one row, which renders
    %% too wide for the content column and shrinks the labels. Keep them.
    rpc ~~~ sink
    evm ~~~ storage
    expr ~~~ maxonce

ModuleCatalog (crates/blockwatcher-core/src/catalog.rs) is where that naming resolves: one name-keyed map per port family, each holding factory functions folded in from every compiled-in module’s own get_all() enumeration. A config that names a module absent from the catalog (a typo, or a module simply not compiled into this build) fails at boot or at write time with EngineError::UnknownModule, listing every alternative that actually is registered, via unknown and the family! macro’s lookup arm (catalog.rs), rather than panicking or silently no-op’ing that pipeline stage. Because the list comes from the catalog itself, the message always names what this particular binary carries, never a superset the workspace merely contains somewhere.

One module, one port, one name

A module never straddles two ports. crates/blockwatcher-evm, for instance, ships two Source implementations (evm-rpc, evm-mempool) and one Decoder (evm): three separate modules living in one crate, each registered under its own name and each satisfying exactly one port’s trait, never blending source and decode logic into a single type, in build_catalog (crates/blockwatcher-embed/src/catalog.rs, folding blockwatcher_evm::sources::get_all() and blockwatcher_evm::decoders::get_all() separately into the catalog). Which module handles which resource is itself fixed by the port: a network names its Source module, a spec’s chain determines its Decoder, a sink resource names its Sink module, blockwatcher.toml names the one Matcher and one Storage backend for the whole process. An operator never picks a module without also picking, structurally, which port it fills.

Trade-offs are a property of the module, not a setting

Because two modules behind the same port are interchangeable at the trait level, they are free to differ arbitrarily in the trade-offs they make. blockwatcher leans on that rather than trying to expose every axis as a tunable knob on one do-everything implementation. The clearest example ships on the Source port, between the EVM sources:

  • evm-rpc scans confirmed blocks and logs over RPC. It only ever reports something once that something has a fixed position in the chain, which is exactly what lets it make at-least-once delivery’s promise: its checkpoint names a real block to resume from, so a crash costs at worst a duplicate, never a gap. The cost is latency and RPC quota: an event is only visible once it is mined and this source has polled far enough to see it.
  • evm-mempool watches a node’s pending-transaction feed instead. It reports a transaction before it is ever mined, which is strictly faster, but at the cost of the one guarantee evm-rpc can make: a pending transaction may never be mined at all, and this source’s cursor is a per-run arrival counter rather than a chain position, so a restart cannot resume it: whatever was pending while the process was down is simply gone. Delivery guarantees covers exactly what that costs a consumer.

Choosing between them is choosing a point on a latency-vs-completeness trade-off, and blockwatcher does not try to collapse that choice into a single configurable source with a “mode” flag: the two behaviors are different enough, and the guarantee difference consequential enough, that they are two separate modules an operator picks between by name, each documenting its own side of the trade-off rather than one module documenting a matrix of settings. The same pattern of “trade-off lives in which module you picked, not in a shared config surface” carries across the other five ports as well: the webhook sink trades a network dependency for delivering anywhere HTTP reaches; the log sink trades reach for having none; sqlite storage trades a single-writer constraint for surviving a restart, memory storage trades restart survival for zero setup; threshold trades per-hit delivery for a session digest that does not stall the checkpoint; max_once trades later in-window hits for a single alert.

The module catalog

Every row below is a module registered into ModuleCatalog by one of the get_all() calls build_catalog (crates/blockwatcher-embed/src/catalog.rs) folds together. The table is exhaustive in both directions: every module compiled into this binary appears here, and every row names something a real registration call, cross-checked against each family’s own enumeration, actually produces.

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.
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.
decoderevmcrates/blockwatcher-evm/src/registry.rsCompiles a Solidity ABI into reusable schemas once per spec write; the only chain family shipped, so it is also the only place chain-specific decode logic exists in this codebase at all.
matcherexprcrates/blockwatcher-expr/src/matcher.rsThe only predicate engine shipped; selected once for the whole process rather than per monitor, trading per-monitor flexibility for one well-tested evaluation path.
gatethresholdcrates/blockwatcher-gates/Session digest: N hits spanning ≤ window_ms of event time fire once, then the bag resets. Holds persist without stalling the checkpoint. Requires block.timestamp.
gatemax_oncecrates/blockwatcher-gates/At most one alert per event-time window; later hits discarded, not dead-lettered. Requires block.timestamp.
sinkwebhookcrates/blockwatcher-sinks/src/webhook.rs (enumerated crates/blockwatcher-sinks/src/registry.rs)Reaches anywhere HTTP does, at the cost of a network dependency and a target that must itself stay reachable and fast enough not to trip the engine’s retry/dead-letter policy. url_secret and, for a header such as Authorization, header_secrets carry destination credentials as env:NAME references resolved per delivery rather than stored plaintext; headers stays for values that are not secret, and one header name cannot appear in both maps.
sinkscriptcrates/blockwatcher-sinks/src/script.rs (enumerated registry.rs)Hands a match to an operator-authored program over stdin (arbitrary local logic), at the cost of owning that program’s own reliability and timeout behavior.
sinklogcrates/blockwatcher-sinks/src/log.rs (enumerated registry.rs)Zero external dependency, one JSON line per match to stdout: the simplest possible delivery target, useful for piping and testing rather than production reach.
storagememorycrates/blockwatcher-storage/src/memory.rs (enumerated crates/blockwatcher-storage/src/registry.rs)No persistence at all: trades restart survival for zero setup, since checkpoints, dead letters, and resources are all gone the moment the process exits.
storagesqlitecrates/blockwatcher-storage/src/sqlite.rs (enumerated registry.rs)Single-file, single-writer, one process: survives a restart, at the cost of the concurrent-writer scaling a networked database would offer instead.

Omit-gate is engine passthrough, not a catalog row an operator must name. Matcher is still one module for the whole process. Gate is per monitor, like sink.

Module selection is by name in configuration; a name the running binary never registered fails loudly, with the list of names it actually did register, rather than silently doing nothing.

Why this is the operator’s actual lever

Because a trade-off lives inside the module rather than in a shared knob, “choose your trade-offs” and “choose your modules” are the same action for an operator. Wanting faster visibility into pending activity, accepting that some of it will never be confirmed, means switching a network’s source from evm-rpc to evm-mempool, not flipping a setting on one source that tries to serve both goals at once. The six-port boundary is what makes that swap safe to make at all: switching sources changes nothing about how the decoder, the matcher, a gate, or any configured sink behaves, because none of them were ever written against one source’s assumptions to begin with.

Guides & reference

This part covers running and operating a deployed instance: the configuration surface, the HTTP API and observability endpoints, a Docker deployment, two fully worked examples, a symptom-first troubleshooting index, and a benchmark baseline. It is for an operator or integrator working against a running blockwatcher, not for someone deciding whether to adopt it.

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,sources,sinks,storage,engine,decoder,matcher,gate dim
class api,metrics focus
click sources "../concepts/selectors.html"
click decoder "../concepts/chain-agnosticism.html"
click matcher "../concepts/predicates.html"
click gate "../concepts/gates.html"
click sinks "../concepts/delivery.html"
click storage "../concepts/resources.html"
click api "http-api.html"
click metrics "observability.html"
click engine "../concepts/pipeline.html"

Key takeaways

  • This part is for operating a running deployment, not for deciding whether to adopt blockwatcher.

  • Configuration and the HTTP API cover how an instance is configured and controlled; observability covers how to watch it run.

  • The two worked examples build the same monitor against confirmed blocks and against pending transactions.

  • Troubleshooting indexes failure modes by symptom rather than by component.

  • Configuration: the instance configuration file and its environment overrides.

  • Resource reference: every resource kind’s fields, defaults, and write-time refusals, one page per kind.

  • HTTP API: the REST control plane.

  • Observability: the status endpoint and the Prometheus metrics.

  • Running with Docker: the Compose stack and its own configuration.

  • Monitoring ERC-20 transfers [RPC]: a worked example against confirmed blocks.

  • Monitoring ERC-20 transfers [Mempool]: the same example against pending transactions instead.

  • Troubleshooting: a symptom-first index into failure modes and their fixes.

  • Benchmarks: what’s measured, how to reproduce it, and the baseline results.

Configuration reference

blockwatcher boots from exactly one instance configuration: an optional TOML file layered with BLOCKWATCHER_* environment overrides, read once at process start and immutable for the rest of the run (crates/blockwatcher/src/config.rs). It covers only the process’s own plumbing: the API listener, the metrics listener, the storage backend, and the engine’s runtime tunables. Everything else (networks, specs, sinks, and monitors) is resource configuration: storage-backed, mutated through the HTTP API or loaded once from a seed directory, and never read from blockwatcher.toml.

InstanceConfig (crates/blockwatcher/src/config.rs) and every section inside it reject an unrecognized field (#[serde(deny_unknown_fields)]), so a typo’d key fails the boot rather than being silently ignored. Every section also defaults, so an empty file (or no file at all, given at least one recognized environment override) boots a working instance: an in-memory store, no API, no metrics.

Loading a configuration

blockwatcher [--config <path>] [--seed <dir>]

Three layers, each one able to override the layer before it:

flowchart LR
    def["InstanceConfig::default()"] --> file["--config file<br/>TOML, if given"]
    file --> env["BLOCKWATCHER_* env vars<br/>win over both"]
    env --> final["final InstanceConfig"]

load (config.rs) builds the configuration in three layers:

  1. InstanceConfig::default(): every section’s own default.
  2. The TOML file at --config <path>, if given. A file that fails to read or parse refuses the boot naming the path, never the offending value.
  3. BLOCKWATCHER_* environment variables, which win over both of the above.

Nested keys use a double underscore: BLOCKWATCHER_API__LISTEN, BLOCKWATCHER_STORAGE__MODULE. Only variables whose name contains __ after the BLOCKWATCHER_ prefix are read as overrides (env_provider, config.rs): a variable such as BLOCKWATCHER_API_TOKEN, the environment variable a token’s secret = "env:BLOCKWATCHER_API_TOKEN" reference names, has no __ in it and is never mistaken for a config key. The token table itself is file-based: Figment numbered env keys cannot spell a sequence, so [[auth.tokens]] rows are not overridden from the environment.

Two aliases exist for one deprecation cycle: BLOCKWATCHER_API_LISTEN and BLOCKWATCHER_METRICS_LISTEN, equivalent to BLOCKWATCHER_API__LISTEN and BLOCKWATCHER_METRICS__LISTEN respectively, applied after everything else so they still win when both an alias and its nested form are set (apply_listen_aliases, config.rs).

Neither a --config path nor a single recognized BLOCKWATCHER_* override is optional: at least one must be present, or the boot refuses with:

no configuration source: provide --config <path> or set BLOCKWATCHER_* variables
(e.g. BLOCKWATCHER_STORAGE__MODULE, BLOCKWATCHER_API__LISTEN)

This is deliberate: an empty file and “no configuration at all” would otherwise both boot the same all-defaults instance, and the second of those is far more likely to be a mistake than an intent.

[api]

The REST control plane. Disabled by default: a listener that appears without an operator having asked for one is a listener nobody remembered to put a token on.

KeyTypeDefaultMeaning
enabledboolfalseServes the HTTP API on listen when true.
listenstring (socket address)"127.0.0.1:8080"Address the API listener binds. Parsed at boot; a value that doesn’t parse as a socket address refuses naming [api].listen.

GET /health on this listener is the only path that answers without a bearer token. Compose healthchecks curl it with no header. Every other path requires a token from [auth].

[auth]

The labelled bearer table. Required (and must be non-empty) when [api].enabled is true. Each row is one credential: a label audit records name, a scope (read, operate, or admin), and a secret that is an env:NAME reference, never a literal. Boot refuses an empty table, two rows that resolve to the same secret (naming the labels, not the value), an unresolvable reference, or a secret that is not of the env:NAME form.

read < operate < admin. The minimum is attached per MethodRouter, not by path prefix: GET of a resource is read; pause/resume/replay are operate; PUT/DELETE of networks, specs, sinks, and monitors are admin. Creating a script sink is therefore not the same privilege as reading the dashboard.

KeyTypeDefaultMeaning
tokensarray of { label, scope, secret }[]The table. Empty when the API is disabled; refused when the API is enabled.

[metrics]

The Prometheus scrape listener. Disabled by default for the same reason as [api].

KeyTypeDefaultMeaning
enabledboolfalseServes /metrics on listen when true.
listenstring (socket address)"127.0.0.1:9090"Address the metrics listener binds. Parsed the same way as [api].listen.

[storage]

Which storage module backs every resource and every checkpoint and dead letter. This section is a ModuleSel (a module name plus that module’s own opaque config object), the same envelope every other module family uses (crates/blockwatcher-types/src/resource.rs).

KeyTypeDefaultMeaning
modulestring"memory"Which storage module to construct.
configobject{}The named module’s own config. Shape depends on module.

blockwatcher ships memory and sqlite storage modules. memory takes no config at all: it persists nothing, which is the floor that lets an instance boot with zero setup and never a durable deployment. sqlite takes:

KeyTypeDefaultMeaning
pathstringnone (required)The database file, or the literal ":memory:" for a database that dies with the process (fine for tests, not for anything meant to survive a restart).
busy_timeout_msu645000How long a call waits on a lock an external reader holds before giving up.

[engine]

The core pipeline’s own tunables, as an operator writes them (EngineSection, crates/blockwatcher/src/config.rs). Every field here is optional; an unset one is left out of the value core’s own deserializer sees (engine_config, config.rs), so the default that applies is whatever blockwatcher-core’s EngineConfig itself defaults to. This binary never restates a number core owns. matcher is the one field this rule doesn’t quite cover: core has no sane default matcher to fall back to on its own, so when this key is unset the binary substitutes the matcher module it was compiled with (expr, when the expr feature is on) rather than leaving the boot to fail on a genuinely absent value.

KeyTypeConfig default (unset)Meaning
event_channel_capacityusize256Bound on the channel between a network’s source and its decode-and-match stage.
sink_channel_capacityusize64Bound on the channel feeding each sink worker.
drain_deadline_msu6410000How long a shutdown waits for in-flight matches to finish delivering before aborting the pipeline (exit code 2).
journal_depthu641024How many cursor-primary units of delivered Match ids the delivery journal retains (EVM: block numbers). Always on; there is no disable switch. Must sit well above the source’s confirmation window so a deep invalidate can still retract by id. Gaps past the window are counted, never silent.
dead_letter_retentionu64, optionalunset (None)How many dead letters one network keeps. Omitting the key keeps every letter, which grows without bound against a sink that keeps failing. A positive value is the cap: recording a letter drops the oldest beyond it in the same write, counted as blockwatcher_dead_letters_pruned_total and logged — a dropped letter can no longer be listed or replayed. 0 is refused at boot: zero is not a spelling of unbounded. A cap above i64::MAX is also refused.
default_retrytable, see belowsee belowDelivery retry policy used by any sink that doesn’t set its own retry.
source_restarttable, see belowsee belowBackoff policy for restarting a pipeline whose source exits on its own.
matchertable ({ module, config })compiled-in matcherWhich matcher module evaluates every predicate. Selected once for the whole instance, never per monitor.

default_retry is the same DeliveryRetry shape a SinkDef’s own retry field carries, validated at boot the same way a sink’s own retry override is validated at write time and at boot:

KeyTypeDefaultMeaning
max_attemptsu328Total delivery attempts, not retries after the first: 1 delivers once and dead-letters on failure; 0 is treated as 1.
initial_backoff_msu64200Delay before the second attempt. Above max_backoff_ms refuses: default_retry.initial_backoff_ms (30001) must be at most default_retry.max_backoff_ms (30000).
max_backoff_msu6430000Cap on the doubling backoff between attempts.

source_restart governs the supervisor that restarts a pipeline whose source task exits before its own cancellation fires, validated at boot on the same terms:

KeyTypeDefaultMeaning
initial_backoff_msu64500Delay before the first restart attempt. Above max_backoff_ms refuses: source_restart.initial_backoff_ms (60001) must be at most source_restart.max_backoff_ms (60000).
max_backoff_msu6460000Cap on the doubling backoff between consecutive restarts.
reset_after_msu64300000How long a network must run without exiting again before its consecutive-exit count resets to zero.

An invalid default_retry, source_restart, or dead_letter_retention fails Engine::start with InvalidEngineConfig, naming the offending field: process config, not a stored resource, so it carries no resource kind or id the way a rejected sink policy does.

matcher is a ModuleSel like [storage]: a module name and that module’s own config object. The shipped expr module takes no config.

Secrets: the env: indirection

A token’s secret is never a literal token: it is a reference of the form "env:NAME", parsed by SecretRef (crates/blockwatcher-types/src/secret.rs) and resolved by reading the named environment variable at the moment it’s needed, never cached and never logged. A value that isn’t of that form refuses the boot naming the required shape and never echoing what was actually written, because the likeliest mistake feeding this key is pasting the real token where a reference belongs. AuthTokenEntry’s own Debug implementation is hand-written for the same reason: it renders secret as <redacted> whenever one is set, so a panic message or a boot-failure log line can never carry it (config.rs). When the API is enabled, boot also refuses a label that is empty, a label used by more than one row, and a reference that resolves to an empty string: audit names the label, and an empty secret would start a listener that can never authenticate.

This indirection isn’t unique to the token table: it’s the general shape blockwatcher uses anywhere a secret has to live in a config object: a webhook sink’s url_secret field, and an evm-rpc or evm-mempool network’s endpoint URLs, are all env:NAME references resolved the same way, per use, by the same SecretRef type. The instance config surfaces those fields on [[auth.tokens]], because they are the secrets this file itself carries; every other env: reference lives in resource configuration (network, sink), set through the API or a seed directory rather than blockwatcher.toml.

CLI flags

Parsed by hand, not through a flag-parsing crate, in parse (crates/blockwatcher/src/cli.rs):

FlagApplies toMeaning
--config <path>blockwatcher [run], prune-checkpointsInstance configuration TOML. Optional for the default run command when BLOCKWATCHER_* env vars supply the whole instance config; required for prune-checkpoints.
--seed <dir>blockwatcher [run]Seeds resources from <dir> on a first boot only: a store that already holds any resource is left untouched.
--dry-runprune-checkpointsLists orphaned checkpoint rows without deleting them.

Three commands, no explicit subcommand name for the default one:

blockwatcher [--config <path>] [--seed <dir>]      # boot and run
blockwatcher check <dir>                            # validate a seed directory
blockwatcher prune-checkpoints --config <path> [--dry-run]
blockwatcher --help      # or -h
blockwatcher --version   # or -V

check <dir> validates a seed directory against the modules actually compiled into the binary and constructs every one of them, so every secret a seed’s configs reference through env:NAME must be present in check’s own environment, exactly as it would need to be at a real boot. prune-checkpoints deletes checkpoint rows whose network resource is gone; deleting a network deliberately leaves its checkpoint behind so a re-created id can resume, and this command is the offline sweep for orphans that will never come back.

Exit codes (cli.rs):

CodeMeaning
0A clean drain (or a stop during boot), or a check that passed.
1A configuration, seed, or boot failure, or a check that refused.
2A shutdown that aborted at least one pipeline at the drain deadline.
64A command line this binary could not parse.

RUST_LOG sets the log filter; unset, it defaults to info rather than silence, so the shutdown report, seeding warnings, and restart notices are still visible by default (init_tracing, crates/blockwatcher/src/lib.rs). Every diagnostic goes to stderr, never stdout. Stdout, when running with no [api] at all, is reserved for sink-event JSON (tagged type: match or type: retracted).

Full annotated example

# blockwatcher instance configuration. Resources (networks, specs, sinks,
# monitors) are managed through the HTTP API or a --seed directory, never
# through this file.

[api]
enabled = true
listen = "0.0.0.0:8080"

[[auth.tokens]]
label = "operator"
scope = "admin"
# A reference, never the token itself: resolved from the environment at
# each request's auth check.
secret = "env:BLOCKWATCHER_API_TOKEN"

[metrics]
enabled = true
listen = "0.0.0.0:9090"

[storage]
module = "sqlite"
config = { path = "/data/blockwatcher.db", busy_timeout_ms = 5000 }

[engine]
event_channel_capacity = 256
sink_channel_capacity = 64
drain_deadline_ms = 10000
journal_depth = 1024
# omit dead_letter_retention to keep every letter; a positive value is the cap
default_retry = { max_attempts = 8, initial_backoff_ms = 200, max_backoff_ms = 30000 }
source_restart = { initial_backoff_ms = 500, max_backoff_ms = 60000, reset_after_ms = 300000 }
matcher = { module = "expr", config = {} }

Every value shown above under [engine] is also that key’s own default: the section could be omitted entirely and the instance would boot identically, apart from matcher, which still resolves to expr as long as the binary was built with that feature.

Resource reference

Everything blockwatcher watches and everywhere it delivers to is one of four resource kinds, each its own Rust struct in crates/blockwatcher-types/src/resource.rs. This section is the field-level reference for an operator writing one: every key, its type, its default, and the exact refusal a bad value gets. What the kinds mean and how they relate lives on Resources; this section owns the tables.

One page per kind:

  • Network: a feed to watch, including the evm-rpc and evm-mempool source module configs.
  • Spec: a chain-tagged decode artifact, including the evm payload shape.
  • Sink: a delivery destination and its engine-owned policies, including the webhook, script, and log module configs.
  • Monitor: the rule tying the other three together, optionally gated.

The two configuration planes

blockwatcher splits configuration by lifetime, not by topic:

  • Instance configuration (blockwatcher.toml plus BLOCKWATCHER_* environment overrides): the process’s own plumbing, read once at boot and immutable for the run. See the Configuration reference.
  • Resource configuration (this section): networks, specs, sinks, and monitors, stored in the storage backend, mutated through the HTTP API or loaded once from a seed directory, and never read from blockwatcher.toml.

Write routes

Every kind answers on the identical CRUD shape (crates/blockwatcher-api/src/routes/resources.rs):

MethodPathMeaning
PUT/networks/{id}, /specs/{id}, /sinks/{id}, /monitors/{id}Create (no If-Match) or update (If-Match: "<version>").
GETsame item paths, plus the bare collection pathsRead one (with its version in ETag) or list all.
DELETEsame item pathsDelete; If-Match is mandatory.

Optimistic concurrency runs on ETag / If-Match:

  • A PUT with no If-Match is a create: 201 with the new version in ETag, or 409 already_exists if the id is already there.
  • A PUT with If-Match: "<version>" is a conditional update: 200 on success, 412 version_conflict (the body carries actual_version) if the version has moved on, 404 not_found if the record is gone.
  • A DELETE without If-Match is 428 precondition_required.

The full route contract, including error bodies and the If-Match format, is on the HTTP API reference.

Seed directories

The binary’s --seed <dir> flag loads resources once, at first boot only (crates/blockwatcher/src/seed.rs):

  • The directory holds exactly one subdirectory per kind: networks/, specs/, sinks/, monitors/, one JSON file per resource.
  • The bundle runs through the same validation Engine::start itself runs, then writes with create-only semantics.
  • Once storage holds any resource of any kind, seeding is refused as a no-op on every later boot: after the first boot, resources are managed exclusively through the API.
  • blockwatcher check <dir> validates a seed directory offline, against the modules compiled into the binary, constructing every one of them; every env:NAME secret the configs reference must be present in check’s own environment.

What a write does to a running pipeline

Hot-reload granularity differs by kind, per crates/blockwatcher-core/src/control/writes.rs and, for the delete noted below, crates/blockwatcher-core/src/control/deletes.rs:

KindEffect of a write
MonitorHot-swap: the running pipeline receives a recompiled monitor set with no restart. Two exceptions restart that one network: a write that changes gate, whose previous envelope’s holds are dropped with the pipeline stopped and the pipeline then brought back (a delete of a gated monitor likewise), and a monitor that names a sink the pipeline never spawned a worker for.
NetworkAlways restarts that network’s pipeline; it resumes from the checkpoint the drain left behind.
SinkRestarts every network whose stored monitors name that sink id.
SpecRestarts every network on the spec’s chain, and on a chain reassignment its prior chain too, because every spec sharing a chain compiles together.

The mechanics, including what a failed fan-out restart does, are on Resources § Lifecycle.

Validation and deletes

Every write validates before it reaches storage, and every validation rejection surfaces on the wire as 422 Unprocessable Entity; the per-kind checks are on Resources § Write-time validation. The refusal messages quoted throughout this section are those checks’ actual output.

A DELETE of a network, spec, or sink refuses outright while any stored monitor still references the id, per refuse_if_referenced (crates/blockwatcher-core/src/control/deletes.rs): delete the monitors first.

Network

A network names one feed to watch: an id, a chain tag, and a source module selection (Network, crates/blockwatcher-types/src/resource.rs). It is written through PUT /networks/{id} on the HTTP API or as one JSON file under a seed directory’s networks/ subdirectory. Writing a network always restarts that network’s pipeline; the restart is cheap because the checkpoint survives it, per Resources § Lifecycle.

A complete network resource. The source.config object is registry_examples/evm_rpc.json from crates/blockwatcher-evm/src/, copied verbatim from the same file the crate’s family-completeness test constructs (the resource envelope around it precludes a literal include); any change to that file updates this example in the same change, per the wiki-parity rule:

{
  "id": "eth-mainnet",
  "chain": "evm",
  "source": {
    "module": "evm-rpc",
    "config": {
      "start_block": 18000000,
      "endpoints": [
        {
          "name": "alchemy",
          "url_secret": "env:BLOCKWATCHER_EXAMPLE_EVM_RPC_URL",
          "priority": "high",
          "rate_limit": { "rps": 25 }
        },
        {
          "name": "public",
          "url_secret": "env:BLOCKWATCHER_EXAMPLE_EVM_RPC_URL_FALLBACK",
          "priority": "low"
        }
      ],
      "confirmations": 12
    }
  }
}

Fields

Network rejects an unrecognized field (#[serde(deny_unknown_fields)]), so a typo’d key fails the write rather than being silently ignored.

KeyTypeDefaultMeaning
idstringnone (required)The network’s name, referenced by every monitor’s network field.
chainstringnone (required)The chain family tag. The write refuses a chain with no loaded decoder; shipped builds load evm.
sourceobjectnone (required)A ModuleSel: which source module feeds this network, plus that module’s own config.

source is the universal module envelope (ModuleSel, resource.rs):

KeyTypeDefaultMeaning
modulestringnone (required)The source module’s name: evm-rpc or evm-mempool in shipped builds.
configobjectnone (required)The named module’s own config; its shape depends on module and is documented per module below.

Write-time validation constructs the named module with the given config, so every refusal quoted below is raised at the write (as a 422) or at seed validation, never discovered later at runtime. Updating an existing network additionally recompiles every monitor already stored for it against the incoming chain, refusing a chain reassignment that would strand them, per put_network (crates/blockwatcher-core/src/control/writes.rs).

Endpoints: the shared pool vocabulary

Both source modules name a pool of HTTP JSON-RPC endpoints with the same shape (EndpointDef, crates/blockwatcher-evm/src/source/endpoint.rs). Each 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-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 Selectors § The position problem.

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.

Spec

A spec is a chain-tagged decode artifact: an id, a chain tag, and a payload that core never interprets (Spec, crates/blockwatcher-types/src/resource.rs). It is written through PUT /specs/{id} on the HTTP API or as one JSON file under a seed directory’s specs/ subdirectory. The write itself compiles the payload (EvmDecoder::compile_spec, crates/blockwatcher-evm/src/decoder/mod.rs), so every refusal quoted below is raised at that write, as a 422, never later; the same compilation also reruns at boot and on pipeline restart, against payloads the write already proved valid. Writing a spec restarts every network on the spec’s chain, and on a chain reassignment its prior chain too, because every spec sharing a chain compiles together, per Resources § Lifecycle.

A complete spec resource. The payload is the two-declaration ERC-20 ABI the decoder’s own tests compile (erc20_spec_with_function, crates/blockwatcher-evm/src/decoder/mod.rs):

{
  "id": "erc20",
  "chain": "evm",
  "payload": [
    {
      "type": "event",
      "name": "Transfer",
      "anonymous": false,
      "inputs": [
        { "name": "from", "type": "address", "indexed": true },
        { "name": "to", "type": "address", "indexed": true },
        { "name": "value", "type": "uint256", "indexed": false }
      ]
    },
    {
      "type": "function",
      "name": "transfer",
      "stateMutability": "nonpayable",
      "inputs": [
        { "name": "to", "type": "address" },
        { "name": "amount", "type": "uint256" }
      ],
      "outputs": [{ "name": "", "type": "bool" }]
    }
  ]
}

Fields

Spec rejects an unrecognized field (#[serde(deny_unknown_fields)]).

KeyTypeDefaultMeaning
idstringnone (required)The spec’s name, referenced by a monitor selector’s spec field.
chainstringnone (required)The chain family whose decoder compiles the payload. The write refuses a chain with no loaded decoder; shipped builds load evm.
payloadJSONnone (required)The chain’s own decode artifact, opaque to core. For evm: a Solidity JSON ABI array, per the contract below.

The evm payload

The evm decoder parses payload as a Solidity JSON ABI array. A payload that does not parse refuses with spec '<id>': payload is not a JSON ABI array: ..., and a spec whose chain does not match the decoder is refused as an unsupported chain.

What the ABI’s declarations become:

  • Events: every named event overload becomes a schema and a decode plan keyed by the keccak256 hash of its signature (the log’s first topic).
  • Functions: every function overload becomes a schema and a decode plan keyed by the 4-byte selector its calldata leads with.

A monitor’s selector then names these declarations by name; see Monitor for the selector keys and Selectors for what each kind decodes.

What the decoder rejects

All of these refuse the write before a CompiledSpec is ever constructed:

  • An anonymous event: spec '<id>' event '<name>' is anonymous: this decoder identifies events by the keccak256 hash of their signature in the log's first topic, which an anonymous event's log never carries, so it could never be matched at decode time.
  • Two events whose selectors collide: spec '<id>': events '<a>' and '<b>' both hash to the same selector; a decoder cannot tell them apart at decode time, so only one may be declared. Two colliding functions refuse with the same message shape.
  • A payload declaring nothing: spec '<id>' declares zero events and zero functions, so no selector against it could ever match anything.
  • A tuple parameter with no components: tuple field '<name>' has no components, so it would disappear from the schema instead of declaring anything; give it at least one component or remove it from the ABI.
  • Two components of one tuple resolving to the same name: tuple field '<name>': the component at position <n> is named '<x>', which another component of the same tuple already carries; give each component in the tuple a distinct name. When the colliding component is unnamed, the message instead reads ... the unnamed component at position <n> would default to '_<n>', which another component of the same tuple already carries; rename that other field to something other than '_<n>'.

The dotted-name flattening contract

Field names in the compiled schema come from the ABI’s parameters, flattened per flatten_field (crates/blockwatcher-evm/src/decoder/compile.rs):

  • A bare tuple parameter flattens into one field per leaf, dotted by component name (name.component, recursively through nested tuples).
  • An unnamed tuple component is named _<position> (_0, _1, …).
  • Every other shape is one field. An array of tuples (tuple[]) never flattens: it becomes a single array-of-map field, so a predicate can address it as a whole but never a named component inside one element.

Each leaf’s ABI type maps to a canonical value family (address, unsigned or signed integer, bytes, bool, string, array, map); how a predicate types against those families is on Predicates § The type system.

The namespaces every evm spec carries

Alongside its declared events, every compiled evm spec carries the same three predicate namespaces (namespaces, crates/blockwatcher-evm/src/decoder/compile.rs). A field a particular occurrence does not carry (tx.from on a log-decoded event, tx.status on a pending transaction) resolves Unknown at predicate time, never an error:

NamespaceFields
txhash (bytes), index (uint), status (uint), from (address), to (address), value (uint)
blocknumber (uint), hash (bytes), timestamp (uint)
logaddress (address), index (uint)

Sink

A sink is a delivery destination: an id, a module name, that module’s config, and optional engine-owned policies (SinkDef, crates/blockwatcher-types/src/resource.rs). It is written through PUT /sinks/{id} on the HTTP API or as one JSON file under a seed directory’s sinks/ subdirectory. Writing a sink restarts every network whose stored monitors name that sink id, per Resources § Lifecycle.

A complete sink carrying every policy field beside a webhook module config, the same example Delivery guarantees § Delivery policies walks through (the policy values shown are each field’s defaults):

{
  "id": "alerts",
  "module": "webhook",
  "config": {
    "url_secret": "env:ALERTS_WEBHOOK_URL",
    "headers": { "x-source": "blockwatcher" },
    "timeout_ms": 10000,
    "body_template": "{% if type == \"digest\" %}{{ matches | length }} matches{% else %}{{ type }}: {{ monitor }}{% endif %}"
  },
  "retry": { "max_attempts": 8, "initial_backoff_ms": 200, "max_backoff_ms": 30000 },
  "throttle": { "max_deliveries": 60, "window_ms": 60000 },
  "aggregate": { "window_ms": 30000, "max_batch": 100 }
}

Fields

SinkDef rejects an unrecognized field (#[serde(deny_unknown_fields)]). retry, throttle, and aggregate are siblings of config, never nested inside it: a sink module never sees or interprets its own policies, so enforcement is identical for every module. These policies run after a match exists. Counting hits or capping alerts before a match is minted is a monitor gate, not a sink field. throttle dead-letters refused matches (replayable); max_once discards them. aggregate stalls the checkpoint for the window; threshold does not. What enforcing each policy means at runtime is documented on Delivery guarantees § Delivery policies; this page owns the fields and their constraints.

KeyTypeDefaultMeaning
idstringnone (required)The sink’s name, referenced by a monitor’s actions list.
modulestringnone (required)Which sink module delivers: webhook, script, or log in shipped builds.
configobjectnone (required)The named module’s own config, documented per module below. Write-time validation constructs the module with it, so a bad config refuses the write as a 422.
retryobjectabsentDelivery retry policy. Absent falls back to the engine-wide [engine].default_retry from the instance configuration.
throttleobjectabsentDelivery admission cap. Absent means not throttled at all: throttling is opt-in.
aggregateobjectabsentDigest batching. Absent delivers every match on its own, unbatched.

retry, throttle, and aggregate are each validated by the same validate call at two points: put_sink (crates/blockwatcher-core/src/control/writes.rs) refuses the write, and validate_and_build (crates/blockwatcher-core/src/engine/boot.rs) runs the identical check against rows already in storage at boot, refusing to start the engine instead. A policy is either absent or fully valid, never partially so.

retry (DeliveryRetry)

KeyTypeDefaultMeaning
max_attemptsu328Total delivery attempts, not retries after the first: 1 delivers once and dead-letters on failure, and 0 is treated as 1, because a delivery that was never attempted cannot be honestly recorded as given up on.
initial_backoff_msu64200Delay before the second attempt. Backoff doubles per attempt; the multiplier is fixed at 2. Above max_backoff_ms refuses: retry.initial_backoff_ms (30001) must be at most retry.max_backoff_ms (30000).
max_backoff_msu6430000Cap on the doubling backoff between attempts.

throttle (Throttle)

KeyTypeDefaultMeaning
max_deliveriesu3260Successful deliveries the window admits before suppressing more. A zero refuses: throttle.max_deliveries (0) must be at least 1.
window_msu6460000The window’s length. A zero refuses: throttle.window_ms (0) must be at least 1. Above one day refuses: throttle.window_ms (86400001) must be at most 86400000; the cap itself is a legal window.

aggregate (Aggregate)

KeyTypeDefaultMeaning
window_msu6430000How long a batch stays open once the first match joins it. A zero refuses: aggregate.window_ms (0) must be at least 1. Above one day refuses: aggregate.window_ms (86400001) must be at most 86400000.
max_batchu32100The batch size that closes the batch immediately. A zero refuses: aggregate.max_batch (0) must be at least 1. Above the cap of 10,000 refuses: aggregate.max_batch (20000) must be at most 10000; the cap itself is a legal batch size.

The caps bound what one open window can cost: every buffered match is held in memory and the checkpoint stays behind all of them until the window closes.

Sink modules

Each module’s example below is copied verbatim from that crate’s registry_examples/*.json file, the same file the crate’s family-completeness test constructs; any change to those files updates these examples in the same change, per the wiki-parity rule.

webhook

POSTs each sink event as JSON to a secret-referenced URL (crates/blockwatcher-sinks/src/webhook.rs). Redirects are never followed (a 3xx fails permanently). Header values come from two maps: headers is plain configuration, and header_secrets names secret references (env:NAME) resolved fresh at each delivery, the same way url_secret is — so a secret Authorization header is supported without ever holding a resolved copy past the request that carries it. A header named in both maps has no defined winner, so the write refuses rather than guessing one. The example config, registry_examples/webhook.json:

{
  "url_secret": "env:BLOCKWATCHER_EXAMPLE_WEBHOOK_URL",
  "headers": { "x-blockwatcher-monitor": "treasury" },
  "timeout_ms": 10000
}
KeyTypeDefaultMeaning
url_secretstringnone (required)An env:NAME reference to where the URL lives, resolved at each delivery. The write refuses a value that is not a reference, and refuses a reference whose variable is absent or does not hold a URL: 'env:NAME' resolves to a value that is not a URL.
headersobject of string to string{}Extra request headers, plain values. A name or value that does not parse refuses with invalid header name '<name>': ... or invalid value for header '<name>': .... A configured content-type replaces the default application/json.
header_secretsobject of string to string{}Extra request headers whose values are env:NAME secret references, resolved at each delivery like url_secret — a resolved value is held no longer than the request that carries it. A name that does not parse refuses the same way headers does. A name that also appears in headers refuses: header '<name>' is set in both 'headers' and 'header_secrets'; ....
timeout_msu6410000Bound on one delivery attempt. A zero refuses: 'timeout_ms' must be greater than 0; 0 makes every attempt time out immediately and dead-letter.
body_templatestringabsentA minijinja template for the request body; absent keeps the canonical sink-event JSON unchanged. Validated at write time by rendering against synthetic match, retracted, and digest events, so a syntax error or a missing filter refuses with invalid webhook body_template: ... rather than dead-lettering the first real delivery. A field the template reads that a real event lacks renders empty. The template semantics, context shape, and the digest type guard are on blockwatcher-sinks § webhook.

script

Runs an operator-authored program per event with the canonical sink-event JSON on stdin (crates/blockwatcher-sinks/src/script.rs). The example config, registry_examples/script.json:

{
  "command": "/usr/local/bin/blockwatcher-notify",
  "args": ["--channel", "ops"],
  "timeout_ms": 30000
}
KeyTypeDefaultMeaning
commandstringnone (required)The program to run. An empty string refuses: script command must not be empty. The path’s existence is checked only at delivery, not at the write: the file may legitimately appear after boot.
argsarray of strings[]Arguments, passed to the program verbatim.
timeout_msu6430000Bounds the stdin write and the exit wait together. A zero refuses with the same message as the webhook’s timeout_ms.
body_templatestringabsentA minijinja template for the bytes piped to the script’s stdin; absent keeps the canonical sink-event JSON unchanged. Validated at write time by rendering against synthetic match, retracted, and digest events, so a syntax error or a missing filter refuses with invalid script body_template: ... rather than dead-lettering the first real delivery. A field the template reads that a real event lacks renders empty. Shares its template semantics, context shape, and the digest type guard with webhook’s body_template, documented on blockwatcher-sinks § script.

The exit-status contract (sysexits):

  • 0: delivered. A script that exits 0 without reading stdin has still delivered; the exit status is the acknowledgement.
  • 75 (sysexits EX_TEMPFAIL): transient, retried by the engine under the sink’s retry policy.
  • Any other exit code: permanent, straight to the dead-letter queue.
  • Death by signal, and the module’s own timeout: transient.

The last 4 KiB of the script’s stderr ride the error message; stdout is ignored.

log

Writes one line of canonical sink-event JSON to the process’s stdout per delivery (crates/blockwatcher-sinks/src/log.rs). It takes no required configuration; the example config, registry_examples/log.json, is the empty object, and any key other than body_template refuses the write:

{}
KeyTypeDefaultMeaning
body_templatestringabsentA minijinja template for the emitted line, rendered before the trailing newline; absent keeps the canonical sink-event JSON unchanged. Validated at write time by rendering against synthetic match, retracted, and digest events, so a syntax error or a missing filter refuses with invalid log body_template: ... rather than dead-lettering the first real delivery. A field the template reads that a real event lacks renders empty. Shares its template semantics, context shape, and the digest type guard with webhook’s body_template, documented on blockwatcher-sinks § webhook.

Monitor

A monitor is the rule that ties the other three kinds together: an id, the network it watches, one or more selectors, an optional predicate, an optional gate, and actions naming the sinks it delivers to (Monitor, crates/blockwatcher-types/src/resource.rs). It is written through PUT /monitors/{id} on the HTTP API or as one JSON file under a seed directory’s monitors/ subdirectory. Writing a monitor usually restarts nothing: the running pipeline hot-swaps the recompiled monitor set in place. Two things restart that one network instead: changing gate, whose previous envelope’s holds are dropped with the pipeline stopped and the pipeline then brought back (deleting a gated monitor takes the same path), and naming a sink the pipeline never spawned a worker for, per Resources § Lifecycle. Dropping holds never drops an already-emitted digest: an emission the old envelope committed but had not yet delivered survives the wipe and is delivered (or dead-lettered) when the pipeline comes back, see Gates § Delivery guarantee.

Monitor
  id         MonitorId
  network    NetworkId
  selectors  RawSelector[]
  predicate  string?          // this monitor's filter, not a shared id
  gate       ModuleSel?       // this monitor's decision rule, like predicate
  actions    SinkId[]

A complete monitor resource, selecting against the erc20 spec from the Spec page:

{
  "id": "usdc-transfers",
  "network": "eth-mainnet",
  "selectors": [
    {
      "spec": "erc20",
      "events": ["Transfer"],
      "addresses": ["0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"]
    }
  ],
  "predicate": "args.value > 1000000",
  "actions": ["alerts"]
}

The same monitor with a threshold gate; omitting gate (the first example) remains valid passthrough:

{
  "id": "usdc-burst",
  "network": "eth-mainnet",
  "selectors": [
    {
      "spec": "erc20",
      "events": ["Transfer"],
      "addresses": ["0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"]
    }
  ],
  "predicate": "args.value > 1000000",
  "gate": {
    "module": "threshold",
    "config": { "count": 3, "window_ms": 3600000 }
  },
  "actions": ["alerts"]
}

Fields

Monitor rejects an unrecognized top-level field (#[serde(deny_unknown_fields)]); a selector entry’s keys are the one exception, checked later by the chain’s decoder rather than by serde, per the selector table below.

KeyTypeDefaultMeaning
idstringnone (required)The monitor’s name; every delivered match carries it.
networkstringnone (required)The network this monitor watches. The write refuses an id that does not exist. Fixed at creation: an update naming a different network is refused (422); delete and recreate the monitor to move it.
selectorsarray of objectsnone (required)What to decode and match, OR’d across entries. An empty array refuses: a monitor needs at least one selector.
predicatestringabsentAn expression over the decoded event; absent matches everything the selectors decode.
gateobjectabsent{ "module", "config" }. Absent = passthrough. See Gates.
actionsarray of stringsnone (required)The sinks to deliver each match to, one or more. The write refuses an id that does not name an existing sink.

The whole monitor (selectors, predicate, and gate) must compile against the live decoder, matcher, and gate catalog before the write is persisted, so every refusal quoted below surfaces as a 422 on the write, per Resources § Write-time validation.

Selector keys

Each selector entry carries a core-readable spec reference plus decoder-owned keys. For the evm decoder those are events, functions, and addresses, all optional; any other key refuses by name at compile time: unknown selector key '<key>' (compile, crates/blockwatcher-evm/src/decoder/selector.rs).

KeyTypeDefaultMeaning
specstringnone (required)The spec this entry decodes against. A missing key refuses: selector requires a 'spec' reference. The spec must exist and share the network’s chain.
eventsarray of stringsabsentEvent names to select from the spec, all overloads of each name. Absence and emptiness mean opposite things, per the rule below.
functionsarray of stringsabsentFunction names to select from the spec, same shape as events.
addressesarray of stringsabsentContract addresses this entry is restricted to; absent means any address. Each entry restricts only the events and functions it names, never a sibling entry’s.

The absent-vs-empty rule

events and functions share one presence matrix:

  • Naming neither selects every event AND every function the spec declares, ABI-scoped rather than chain-wide, mirroring how an absent addresses means any address.
  • Naming either selects only what it names; the omitted kind is left unselected.
  • An explicit empty array is refused rather than treated as absence, because the two spellings mean opposite things and the empty one would compile an entry that can never fire: selector's 'events' is empty: omit the key to select every declaration of that kind in the spec, or name at least one (functions and addresses refuse with the same shape; the addresses message ends omit the key to match any address, or name at least one).

What else a selector refuses

  • A non-string entry is refused by position, never silently dropped: selector's 'events' entry at position 1 is not a string; every entry must be a name.
  • A name the spec does not declare is refused with a suggestion, the spec’s first declared event or function of that kind: unknown field 'Transfr' plus did you mean 'Approval'? (see Resources § The did-you-mean suggestion).
  • An address must be a 0x-prefixed 40-hex-digit string: address '<raw>' must be a 0x-prefixed 40-hex-digit string.
  • A mixed-case address claims an EIP-55 checksum and is held to it: address '<raw>' is mixed-case, which claims an EIP-55 checksum, but the checksum does not match; check for a mistyped character. An all-lowercase or all-uppercase spelling makes no checksum claim and is accepted as-is.

What a selected event or function actually decodes to, and which raw material reaches the selector per source, is documented on Selectors.

Predicate

predicate is one expression string, compiled at write time against the schemas the monitor’s selectors produce; a field or namespace it names that no selected schema declares refuses the write, with a bounded edit-distance suggestion. The language (syntax, operators, the three-valued evaluation, and the type families) is documented on Predicates and the expression language.

Gate

gate compiles at write against the same schemas as the predicate. Refusals (all 422):

  • unknown field on the envelope or inside config (deny_unknown_fields)
  • unknown module (message lists catalog gate names)
  • window_ms 0 or > 86400000: same bound language as sink throttle.window_ms
  • threshold.count < 2 or > 10000
  • missing block.timestamp on the schema: gate requires 'block.timestamp'; this monitor's selectors do not expose it

Changing module or config drops persisted holds for that monitor; they are not migrated.

HTTP API reference

blockwatcher’s REST control plane is an axum router mounted on the [api].listen address from instance configuration. Every mutation goes through the same ControlHandle a --seed load and the engine’s own boot path use, and every read goes through core’s typed storage facade without taking any lock the engine holds: a read stays answerable while a write is in flight. There is no path prefix: routes are exactly as shown below, against the listener’s own address, assembled in router (crates/blockwatcher-api/src/serve.rs, lib.rs).

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,sources,sinks,storage,metrics,engine,decoder,matcher,gate dim
class api focus
click sources "../concepts/selectors.html"
click decoder "../concepts/chain-agnosticism.html"
click matcher "../concepts/predicates.html"
click gate "../concepts/gates.html"
click sinks "../concepts/delivery.html"
click storage "../concepts/resources.html"
click api "http-api.html"
click metrics "observability.html"
click engine "../concepts/pipeline.html"

Key takeaways

  • Every route except GET /health requires a bearer token from the labelled [auth] table, checked in constant time; a missing or unknown token is 401, a valid token with too weak a scope is 403. Every failure returns the same ErrorBody shape.
  • Writes go through the same ControlHandle a --seed load and the boot path use; reads go through core’s typed storage facade without taking any lock a write holds.
  • Every resource kind shares one CRUD shape with ETag/If-Match optimistic concurrency: PUT with no If-Match creates, with If-Match updates, and DELETE requires If-Match.
  • A monitor test is a dry run: it reports matches without delivering to any sink, registering a cursor, or moving a checkpoint.
  • Network operations (pause, resume, skip, checkpoint delete, dead letters) act on persisted state and the running pipeline directly; none of them replay through the normal ingestion path.

Every request travels the same shape, from the door to the response body:

flowchart LR
    req["HTTP request"] --> health{"path is /health?"}
    health -->|"yes"| ok["200 ok<br/>no token required"]
    health -->|"no"| auth{"require_bearer"}
    auth -->|"missing/wrong"| e401["401 unauthorized"]
    auth -->|"valid"| scope{"require(min scope)"}
    scope -->|"too weak"| e403["403 forbidden"]
    scope -->|"allowed"| route{"read or write?"}
    route -->|"write"| handle["ControlHandle<br/>same path --seed and boot use"]
    route -->|"read"| facade["core's typed<br/>storage facade"]
    handle --> resp["JSON response"]
    facade --> resp

Bodies are JSON in both directions unless noted. The route table is grouped below the same way the route modules split it: health, status, schema, resources, monitor operations, network operations (crates/blockwatcher-api/src/routes/mod.rs).

Authentication

Every route except GET /health requires:

Authorization: Bearer <token>

checked by require_bearer (crates/blockwatcher-api/src/auth/mod.rs) against the labelled [auth] table. Each row’s secret is an env:NAME reference resolved at request time; the process never holds a copy of the value between requests. The comparison is constant-time on length-equal inputs; scheme matching is case-insensitive per RFC 7235. /health is exempted by an explicit path check ahead of the router, not by a separate mount, so a request to a path that genuinely doesn’t exist is refused for lacking a token before the router gets a chance to reveal that it wouldn’t have matched anyway. A valid token is then authorized per MethodRouter by require!(Scope::…): read lists and reads, operate pauses/resumes/ replays, admin writes resources (including script sinks).

A missing or unknown token gets:

{ "error": { "code": "unauthorized", "message": "a bearer token is required" } }

with status 401 and a WWW-Authenticate: Bearer response header.

curl -H "Authorization: Bearer $BLOCKWATCHER_API_TOKEN" http://127.0.0.1:8080/status

Errors

Every failure (an engine refusal or one the API layer decides on its own) leaves through the same body shape, ErrorBody/ErrorDetail (crates/blockwatcher-api/src/error.rs):

{
  "error": {
    "code": "version_conflict",
    "message": "version conflict: expected 3, actual 5",
    "actual_version": 5
  }
}

actual_version appears only on a 412 version conflict; every other error omits it. A 500 always answers with a fixed generic body, {"error":{"code":"internal","message":"internal error"}}, regardless of what actually failed; the real detail goes to the server log only, never the wire, in body and into_response (error.rs).

StatusCodeWhen
400malformed_if_matchIf-Match present but not a quoted integer ETag.
401unauthorizedMissing or incorrect bearer token.
404not_foundNo record at that id.
409already_existsPUT with no If-Match against an id that already exists.
409conflictCurrent state refuses the request (e.g. skip on a network that isn’t paused, checkpoint delete while the network resource exists).
409checkpoint_provenanceA stored checkpoint was written by a different source module than the one now configured.
412version_conflictIf-Match named a version storage no longer has; body carries actual_version.
413too_many_payloadsA dry-run request exceeded the input cap.
422invalid_resourceThe body, or a referenced resource, doesn’t validate.
422unknown_module / unsupported_chain / compile_failed / module_init_failed / missing_reference / still_referencedOther write-time validation refusals: see Resources.
428precondition_requiredDELETE sent with no If-Match.
502replay_failedA dead-letter replay exhausted its retry budget again.
503unavailableA dependency (e.g. an RPC tip lookup) is temporarily unreachable.
500internalA server-side fault; detail is in the log, never the wire.

Health

GET /health

The one route served without a token: a liveness probe has to answer even when secrets aren’t available yet. Asserts nothing about any pipeline.

Response 200:

{ "status": "ok" }
curl http://127.0.0.1:8080/health

Status

GET /status

A point-in-time snapshot of every currently running, paused, or abandoned pipeline, sorted by network id (blockwatcher_core::EngineStatus, crates/blockwatcher-core/src/status.rs).

Response 200, one entry per network in pipelines:

{
  "instance": "b3f1a2c4-9e21-4a6a-8c3e-1f2d3a4b5c6d",
  "paused_monitors": ["usdc-sepolia-alerts"],
  "pipelines": [
    {
      "network": "sepolia",
      "source": { "status": "catching_up", "behind": 42 },
      "checkpoint": { "cursor": { "primary": 11424039, "secondary": 54 }, "module": "evm-rpc" },
      "head": { "primary": 11424081, "secondary": 0 },
      "lag": 42,
      "in_flight_events": 0,
      "event_queue": { "len": 0, "capacity": 1000 },
      "sink_queues": { "log-sink": { "len": 0, "capacity": 100 } },
      "counters": {
        "decoded": 118, "undecodable": 0, "matched": 12, "match_errors": 0,
        "delivered": 12, "dead_lettered": 0, "dispatch_failed": 0,
        "checkpoint_write_failed": 0, "dead_letter_write_failed": 0,
        "misrouted": 0, "checkpoint_regressions_refused": 0,
        "untimestamped": 0, "gate_persist_failed": 0, "gate_hits_dropped": 0,
        "gated": 0, "gate_emitted": 0
      },
      "dead_letter_count": 0,
      "gate_outbox_depth": 0,
      "paused_monitors": []
    }
  ]
}

instance is an opaque per-process identity; two reads returning different values mean the engine restarted in between. It is null when the composition root embedding blockwatcher supplies none.

Top-level paused_monitors is every monitor an operator has paused, as persisted — pipeline membership does not enter into it, so a monitor on a paused network still appears here. It is null when storage would not answer, deliberately never an empty list: a caller cannot tell “nothing is paused” from “the store did not answer” from an empty list, and this field is what a consumer reads as the truth about pause.

source.status is one of starting, live, degraded (carries reason), catching_up (carries behind), paused (the control-plane pause view, never something a source itself reports), or abandoned (no running pipeline and no pause — a failed restart left the network unattended). checkpoint is null until the first event on that network fully completes; lag is null whenever either side it subtracts is unknown.

dead_letter_count is read fresh from storage on every call — unlike counters.dead_lettered, an in-memory count that resets to 0 whenever this pipeline restarts (a sink or monitor edit restarts every network that references it). A caller that wants “how many dead letters exist right now” should read dead_letter_count, not the counter. It is null, never 0, when that storage read fails.

gate_outbox_depth is how many committed gate emissions are still awaiting delivery: the durable outbox rows written when a gate emitted that no sink worker has settled yet. Also read fresh from storage on every call, so a paused network’s pending emissions stay visible for as long as they sit undelivered; a non-zero depth on a paused pipeline means a resume still owes deliveries. Like dead_letter_count, it is null, never 0, when the storage read fails.

A pipeline entry’s own paused_monitors is the monitors that running pipeline is actually suppressing, read from its published set. Compare with the top-level field: that is what an operator asked for, this is what is in force. They diverge only when a republish failed after a persisted pause, which is logged and repaired by the next rebuild.

curl -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8080/status

Schema

GET /specs/{id}/schema

The whole vocabulary a predicate written against this spec may address: compiled through the spec’s chain decoder, not read off the raw stored payload, so it’s the decoder’s own translation rather than the ABI or IDL text itself.

Response 200 (blockwatcher_types::SchemaSet):

{
  "events": [
    { "name": "Transfer", "kind": "event", "fields": [
      { "name": "from", "ty": "address" },
      { "name": "value", "ty": "uint" }
    ] }
  ],
  "namespaces": {
    "tx": [{ "name": "hash", "ty": "bytes" }]
  }
}

Errors: 404 not_found if the spec doesn’t exist.

curl -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8080/specs/usdc-erc20/schema

Resources: networks, specs, sinks, monitors

Every resource kind answers on the identical CRUD shape, generated once by the resource_routes! macro so a client written against any kind works against all of them (crates/blockwatcher-api/src/routes/resources.rs):

MethodPath
GET/networks, /specs, /sinks, /monitors
GET/networks/{id}, /specs/{id}, /sinks/{id}, /monitors/{id}
PUT/networks/{id}, /specs/{id}, /sinks/{id}, /monitors/{id}
DELETE/networks/{id}, /specs/{id}, /sinks/{id}, /monitors/{id}

The collection GET returns the bare list, unpaginated: resource counts are operator-scale, not event-scale. Every item route carries optimistic concurrency through ETag / If-Match:

  • GET {id} returns the resource with its current version in ETag ("3").
  • PUT {id} with no If-Match is a create: 201 with the new version in ETag, or 409 already_exists if the id is already there. PUT {id} with If-Match: "<version>" is a conditional update: 200 with the new version on success, 412 version_conflict (body carries actual_version) if the version has moved on, 404 not_found if the record is gone. The path’s id and the body’s id field must agree, or the write is refused as 422 invalid_resource.
  • DELETE {id} requires If-Match: "<version>": omitting it is 428 precondition_required, since a delete has no later read that would reveal it clobbered a change it never saw. A version that doesn’t match is 412 version_conflict; success is 204.

If-Match takes a strong, quoted, numeric ETag exactly as GET returned it ("3"); anything else is 400 malformed_if_match.

Resource bodies, from crates/blockwatcher-types/src/resource.rs (every shape rejects an unrecognized field except Monitor.selectors[], whose extra keys are decoder-owned and checked later, at compile time):

Network: source.config’s shape is the named module’s own; for evm-rpc (EvmRpcConfig, crates/blockwatcher-evm/src/source/rpc/config.rs) that’s a pool of endpoints (each an EndpointDef with name, a url_secret reference, optional priority/rate_limit/weight, crates/blockwatcher-evm/src/source/endpoint.rs), a required start_block, and the module’s own tunables:

{
  "id": "sepolia",
  "chain": "evm",
  "source": {
    "module": "evm-rpc",
    "config": {
      "start_block": 11424310,
      "endpoints": [
        { "name": "primary", "url_secret": "env:SEPOLIA_RPC_URL", "priority": "high", "rate_limit": { "rps": 10 } }
      ],
      "confirmations": 12,
      "max_lag_blocks": 100,
      "poll_interval_ms": 3000,
      "logs_window": { "initial": 1000, "max": 5000 },
      "probe_interval_ms": 30000
    }
  }
}

Spec: payload is the chain’s own decode artifact, opaque to core (a Solidity ABI array for evm):

{ "id": "usdc-erc20", "chain": "evm", "payload": [ { "type": "event", "name": "Transfer", "inputs": [] } ] }

SinkDef: retry is optional; a sink with none falls back to [engine].default_retry. throttle is optional too, and caps how often the sink admits deliveries; a sink with none is not throttled at all, and a present throttle object fills in any omitted field with 60 deliveries per 60,000ms. aggregate is optional as well, and batches several matches into one digest delivery; a sink with none delivers every match on its own, unbatched. See Delivery policies for what each field means and how a rejection is enforced:

{
  "id": "ops-slack",
  "module": "webhook",
  "config": { "url_secret": "env:OPS_SLACK_WEBHOOK_URL" },
  "retry": { "max_attempts": 5, "initial_backoff_ms": 200, "max_backoff_ms": 30000 },
  "throttle": { "max_deliveries": 60, "window_ms": 60000 },
  "aggregate": { "window_ms": 30000, "max_batch": 100 }
}

Monitor: predicate is optional (no predicate matches everything the selectors decode); actions names one or more SinkDef ids:

{
  "id": "usdc-sepolia-transfers",
  "network": "sepolia",
  "selectors": [{ "spec": "usdc-erc20", "addresses": ["0x1c7D..."], "events": ["Transfer"] }],
  "predicate": "args.value > 1_000e6",
  "actions": ["ops-slack"]
}

An optional gate is a sibling of predicate. Absent is passthrough:

{
  "id": "usdc-burst",
  "network": "sepolia",
  "selectors": [{ "spec": "usdc-erc20", "addresses": ["0x1c7D..."], "events": ["Transfer"] }],
  "predicate": "args.value > 1_000e6",
  "gate": { "module": "threshold", "config": { "count": 3, "window_ms": 3600000 } },
  "actions": ["ops-slack"]
}

Errors common to every write: 422 codes covering unresolvable references, an uncompilable predicate or gate, an unconstructible module config, and so on: see Resources: write-time validation for exactly what’s checked per kind, and 404 not_found / 409 already_exists / 412 version_conflict for the concurrency cases above. DELETE on a Network, Spec, or SinkDef additionally refuses as 422 still_referenced while any stored monitor still names that id.

A gate write is 422 when:

  • the module name is unknown (the message lists catalog gate names, the same shape as an unknown sink module)
  • this monitor’s selectors do not expose block.timestamp: gate requires 'block.timestamp'; this monitor's selectors do not expose it
  • count or window_ms is out of range (threshold.count is 2..=10000; window_ms is 1..=86400000)
curl -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8080/monitors

etag=$(curl -s -o /dev/null -w '%header{etag}' \
  -H "Authorization: Bearer $TOKEN" \
  http://127.0.0.1:8080/monitors/usdc-sepolia-transfers)

curl -X PUT http://127.0.0.1:8080/monitors/usdc-sepolia-transfers \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -H "If-Match: $etag" \
  -d '{"id":"usdc-sepolia-transfers","network":"sepolia","selectors":[{"spec":"usdc-erc20","addresses":["0x1c7D..."],"events":["Transfer"]}],"predicate":"args.value > 2_000e6","actions":["ops-slack"]}'

curl -X DELETE http://127.0.0.1:8080/monitors/usdc-sepolia-transfers \
  -H "Authorization: Bearer $TOKEN" -H "If-Match: \"4\""

Monitor operations

POST /monitors/{id}/pause
POST /monitors/{id}/resume
POST /monitors/{id}/test

pause and resume persist the monitor’s pause: a process restart does not clear it, and every path that rebuilds the monitor set — a network, sink, or spec write that restarts the pipeline, or a fresh boot — reads the persisted set back, per the pause handler’s own doc comment, which covers both operations (crates/blockwatcher-api/src/routes/monitors_ops.rs). The persist is what succeeds; publishing the change to a pipeline that happens to be running is best-effort, logged rather than reported on failure. Both answer 204 on success.

test is a dry run: it reports every match the monitor’s current selectors, predicate, and gate (fresh empty journal, no persist of gate_hits) would produce against supplied input, delivering nothing: no sink is called, no cursor is registered, and no checkpoint moves, which is what makes it safe to point at a monitor serving live traffic. The request body is exactly one of payloads (caller- supplied raw payloads) or fetch (a bounded history read through the network’s own source module):

{ "payloads": [ { "json": { "from": "0x...", "value": "1000000" } } ] }
{ "fetch": { "from": { "primary": 11424000, "secondary": 0 }, "to": { "primary": 11424100, "secondary": 0 }, "limit": 50 } }

Response 200:

{
  "results": [
    { "matched": [ { "id": "...", "monitor": "usdc-sepolia-transfers", "network": "sepolia", "event": { "kind": "event", "name": "Transfer", "fields": {}, "cursor": { "primary": 0, "secondary": 0 } } } ], "undecodable": 0, "no_match": false, "eval_errors": 0 }
  ],
  "explanations_supported": true,
  "held": 0,
  "dropped": 0
}

held and dropped live on the report (not each result): they count this request’s gate Retain and Discard decisions. A monitor with no gate leaves both at 0. One payload against threshold with count: 3 returns matched: [] and held: 1. Three in-window payloads fire one digest whose matches are flattened into matched.

Each result’s explanation field is present only when a decoded event didn’t match and the matcher module was consulted for why: omitted when nothing needed explaining, JSON null when the matcher has no explain support, and the explanation tree otherwise.

no_match is true when no selector wanted this input at all — a log at the wrong address/topic0, a function no monitor named — as opposed to a decoded event the predicate rejected, or an unremarkable input with nothing to report. All three otherwise leave matched empty and undecodable/eval_errors at 0; no_match is what tells them apart.

Errors: 422 invalid_resource when both or neither of payloads / fetch is present, or when fetch targets a source that can’t fetch history (message "this source cannot fetch history"); 413 too_many_payloads past the shared cap of 100 inputs (blockwatcher_core::MAX_TEST_INPUTS).

curl -X POST http://127.0.0.1:8080/monitors/usdc-sepolia-transfers/pause \
  -H "Authorization: Bearer $TOKEN"

curl -X POST http://127.0.0.1:8080/monitors/usdc-sepolia-transfers/test \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"payloads":[{"json":{"from":"0x1","to":"0x2","value":"5000000"}}]}'

Network operations

POST   /networks/{id}/pause
POST   /networks/{id}/resume
POST   /networks/{id}/skip
DELETE /networks/{id}/checkpoint
GET    /networks/{id}/dead-letters
DELETE /networks/{id}/dead-letters
POST   /networks/{id}/dead-letters/{match_id}/replay
DELETE /networks/{id}/dead-letters/{match_id}

pause and resume persist the network’s pause in exactly the same sense as the monitor operations above: a restart does not clear it, and a paused network is never spawned at boot, per the pause handler’s own doc comment (crates/blockwatcher-api/src/routes/networks_ops.rs). pause cancels and drains the running pipeline as part of the same request; resume restarts it. Both answer 204 on success; a restart failure inside resume, after the persisted pause has already been cleared, is logged rather than returned as an error.

Skip

POST /networks/{id}/skip

Moves a paused network’s checkpoint and source start_block forward, without replaying the gap in between: catch-up while the operator has decided it isn’t worth reading.

{ "to": "tip" }
{ "to": { "block": 11424100 } }

Response 200 (blockwatcher_core::SkipReport):

{ "network": "sepolia", "cursor": { "primary": 11424100, "secondary": 0 }, "start_block": 11424100, "previous_checkpoint": { "cursor": { "primary": 11423000, "secondary": 12 }, "module": "evm-rpc" } }

Refusal cases, all named explicitly rather than left to fall through a generic status:

  • Not paused: 409 conflict: "cannot skip network '{id}' while it is not paused; pause it first".
  • Rewind: 422 invalid_resource when the target is behind the current checkpoint: "cannot skip to block {N}: checkpoint is already at {M}".
  • Unsupported tip: 422 invalid_resource, "this source cannot report a confirmed tip", when "to": "tip" targets a source module that has no way to answer it (e.g. evm-mempool).
  • A tip lookup that is merely unreachable right now (a transient RPC failure) is 503 unavailable instead, distinct from the source genuinely not supporting the concept at all.

Checkpoint delete

DELETE /networks/{id}/checkpoint

An explicit reset for an orphaned checkpoint (one whose network resource has already been deleted). Deleting a network deliberately leaves its checkpoint behind so a network re-created under the same id resumes where the old one stopped; this route is how an operator clears that row for good once they know it won’t be reused. The checkpoint is the only row a network delete leaves waiting: pending gate emissions are dead-lettered by the delete itself (see the gate delivery guarantee), so they show up in this network’s dead letters rather than sitting in an outbox nothing reads.

Response: 204 on success.

Errors: 409 conflict ("cannot reset checkpoint for network '{id}' while the network resource still exists") while the network resource is still there; 404 not_found if there’s no checkpoint for the id at all.

Dead letters

GET    /networks/{id}/dead-letters
DELETE /networks/{id}/dead-letters
POST   /networks/{id}/dead-letters/{match_id}/replay
DELETE /networks/{id}/dead-letters/{match_id}

A dead letter can be listed, replayed, or discarded without an attempt at delivery, singly or in bulk. Discarding is permanent and does not consult the sink at all; replaying is the only route that resends a letter.

These routes answer for any id that still holds letters, whether or not its network resource exists: a deleted network’s letters (including the ones its own delete minted from pending gate emissions) stay listable, replayable (the sink resource outlives the network), and discardable. An id with neither a network resource nor letters is 404 not_found, so a typo’d id still refuses rather than answering an empty list.

GET takes offset and limit query parameters (limit defaults to 100) and pages the queue in insertion order:

curl -H "Authorization: Bearer $TOKEN" \
  "http://127.0.0.1:8080/networks/sepolia/dead-letters?offset=0&limit=50"

Response 200:

{
  "entries": [
    {
      "match_id": "5f1136ec114001c7f726218b59527365",
      "monitor": "usdc-sepolia-transfers",
      "sink": "ops-slack",
      "cursor": { "primary": 11424039, "secondary": 54 },
      "attempts": 5,
      "reason": "permanent: webhook host returned 500 five times",
      "payload": { "type": "match", "id": "5f1136ec...", "monitor": "usdc-sepolia-transfers", "network": "sepolia", "event": {} }
    }
  ]
}

payload is omitted for a letter recorded before replay support existed, which is exactly the case replay below refuses outright, since there is nothing to resend. A stored payload is a tagged SinkEvent (type: match or type: retracted). Legacy rows that stored a bare Match object still load as type: match.

POST /networks/{id}/dead-letters/{match_id}/replay

Rebuilds the letter’s sink from its stored config and re-enters the same retry path a live delivery uses. Success deletes the letter: 204. Exhaustion leaves it queued with attempts and reason updated in place and answers 502 replay_failed. A letter with no stored payload refuses as 422 invalid_resource with the message "dead letter has no replay payload (recorded before replay support)" rather than attempting anything. A letter whose payload is a retraction refuses as 422 invalid_resource with "dead letter payload is a retraction; only match events can be replayed".

Identity is (match_id, sink): MatchId is not unique across sinks. ?sink= may be omitted when that match id is unique on the network; otherwise the call answers 409 conflict with "match '{id}' is dead-lettered for more than one sink; pass sink to name the row". The same query applies to the single-letter DELETE below.

curl -X POST \
  "http://127.0.0.1:8080/networks/sepolia/dead-letters/5f1136ec114001c7f726218b59527365/replay?sink=ops-slack" \
  -H "Authorization: Bearer $TOKEN"
DELETE /networks/{id}/dead-letters

Discards every letter matching the optional sink and monitor query filters, without attempting delivery on any of them. Absent filters means “any”; both present combine as AND. Nothing matching discards zero, which is not an error.

Response 200:

{ "discarded": 3 }
curl -X DELETE \
  "http://127.0.0.1:8080/networks/sepolia/dead-letters?sink=ops-slack" \
  -H "Authorization: Bearer $TOKEN"
DELETE /networks/{id}/dead-letters/{match_id}

Discards one letter by its match id, without attempting delivery. The optional ?sink= query is the same disambiguation as replay: omit it only when the match id is unique.

Response: 204 on success.

Errors: 404 not_found when no letter with that match id (and sink, if given) exists — which is what lets a caller tell a race against a concurrent replay from a success. 409 conflict when the match id is queued for more than one sink and sink was omitted.

curl -X DELETE \
  "http://127.0.0.1:8080/networks/sepolia/dead-letters/5f1136ec114001c7f726218b59527365?sink=ops-slack" \
  -H "Authorization: Bearer $TOKEN"

Observability

blockwatcher exposes two independent views of a running instance: the HTTP API’s GET /status (a point-in-time JSON snapshot of every pipeline, covered on that page) and a Prometheus scrape endpoint carrying the same events as cumulative counters and gauges since process start. This page covers the second one: how to turn it on, exactly what it exports, and what to alert on.

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,sources,sinks,storage,api,engine,decoder,matcher,gate dim
class metrics focus
click sources "../concepts/selectors.html"
click decoder "../concepts/chain-agnosticism.html"
click matcher "../concepts/predicates.html"
click gate "../concepts/gates.html"
click sinks "../concepts/delivery.html"
click storage "../concepts/resources.html"
click api "http-api.html"
click metrics "observability.html"
click engine "../concepts/pipeline.html"

Key takeaways

  • blockwatcher exposes two independent views of a running instance: /status (point-in-time JSON) and /metrics (cumulative Prometheus counters and gauges), forked from the same events but never derived from each other.
  • The metrics listener is disabled by default and, once enabled, runs on its own socket with no authentication and no relation to the API’s bearer token.
  • Every pipeline metric is defined once as a named constant and carries a pipeline label; sink-scoped emissions also carry a sink label except the counters marked “pipeline only”.
  • blockwatcher-rpc’s connection pool and evm-mempool sources publish their own metrics under separate prefixes, outside the per-network pipeline contract.
  • A rising dead-letter count, a growing in_flight_events, and a restart failure are the alerting signals this page calls out explicitly.

Turning it on: [metrics]

Disabled by default, for the same reason [api] is: a listener nobody asked for is a listener nobody remembered to firewall.

[metrics]
enabled = true
listen = "127.0.0.1:9090"

Once enabled, GET /metrics on listen serves Prometheus exposition text (crates/blockwatcher-metrics/src/lib.rs): no path prefix, no authentication, and no relation to the API’s labelled [auth] table. The metrics listener is a separate axum::Router on its own socket, bound independently of the control plane. A deployment that wants the scrape endpoint restricted to its own network should bind it to a private address or firewall the port; blockwatcher itself puts nothing in front of it. Recorder upkeep (flushing accumulated Prometheus internals) runs on a 5-second interval for as long as the process is up, so a scrape always sees fresh state rather than data queued since the last request. See Configuration reference for the full [metrics] section and its BLOCKWATCHER_METRICS__LISTEN override.

The pipeline metric contract

Every metric below is defined once, as a named constant, in crates/blockwatcher-core/src/metrics.rs: the module’s own doc comment calls these names “a contract: an exporter and its dashboards are built against them,” fixed once shipped. Every emission carries a pipeline label naming the network id; an emission scoped to one sink also carries a sink label, except the rows marked “pipeline only” below, which stay pipeline-scoped even though the event they count is sink-adjacent. An emission scoped to one gate also carries monitor and gate (module name).

Full metric reference
MetricTypeLabelsMeaning
blockwatcher_events_decoded_totalcounterpipelineOne raw payload decoded successfully against one active monitor’s selector. Counted per monitor-decode attempt, not per raw event: one payload against three active monitors adds up to three.
blockwatcher_events_undecodable_totalcounterpipelineA payload a monitor’s selector could not decode, counted per monitor-decode attempt for the same reason as above.
blockwatcher_matches_totalcounterpipelineA decoded event whose monitor had no predicate, or whose predicate evaluated true.
blockwatcher_match_errors_totalcounterpipelineA predicate that type-checked at write time nonetheless failed to evaluate at runtime. Should stay at zero; a non-zero value points at a matcher bug, not an operator mistake.
blockwatcher_deliveries_totalcounterpipeline, sinkA sink event delivered successfully to one sink: a lone match, a retraction, or a digest. A Digest counts as one delivery however many matches it bundled: this counter tracks deliveries, not matches, so an aggregating sink’s value here is lower than its match count by design.
blockwatcher_digest_deliveries_totalcounterpipeline, sinkA SinkEvent::Digest delivered successfully to one sink. Every digest delivery also increments blockwatcher_deliveries_total once, the same as any other successful delivery; this counter narrows that total to the digest share, so blockwatcher_digest_deliveries_total / blockwatcher_deliveries_total for one sink is the fraction of its deliveries that were batched rather than single matches.
blockwatcher_delivery_retries_totalcounterpipeline, sinkOne delivery attempt that failed and is about to retry, incremented per retry, not per final outcome.
blockwatcher_dead_letters_totalcounterpipeline, sinkA sink event durably recorded as a dead letter: a delivery (match or retraction) that exhausted its retry budget, or a match a throttle policy suppressed without attempting (its record carries attempts 0). A dead-lettered digest adds one row here per contained match, never one row for the digest as a whole.
blockwatcher_dead_letters_pruned_totalcounterpipeline (pipeline only)The dead_letter_retention cap dropped the oldest dead letter(s) for this pipeline as part of the same write that recorded a new one. Deliberately carries no sink label: retention is a per-pipeline cap, not a per-sink one. Fires from either place a dead letter is recorded: a sink’s own delivery exhaustion, or the one-shot retract path during an invalidate. A non-zero rate means an operator can no longer list or replay everything that failed on that pipeline; omitting the key (the default) never fires.
blockwatcher_deliveries_throttled_totalcounterpipeline, sinkA Match a sink’s throttle policy suppressed and dead-lettered instead of attempting, because that sink had already spent its window’s delivery budget. A failed delivery never counts here: only a success spends budget, so this counts admission refusals, not delivery failures. Counted per suppressed match, not per suppressed delivery: a throttled digest of N matches adds N here, agreeing with the N dead-letter rows it produces, even though the digest itself spent only one unit of the throttle budget.
blockwatcher_dispatch_failed_totalcounterpipeline (pipeline only)A match that never reached a sink worker at all: the worker’s channel was closed, or (should be unreachable) the monitor named a sink with no running worker. Distinct from a dead letter: this never got as far as a delivery attempt.
blockwatcher_events_misrouted_totalcounterpipelineA raw event arrived carrying a different network’s id than the pipeline it reached. Always zero in a correctly wired deployment; non-zero means a source is emitting for someone else’s network.
blockwatcher_checkpoint_write_failures_totalcounterpipelineA checkpoint persist that failed against storage and is being retried on a doubling backoff.
blockwatcher_checkpoint_regressions_refused_totalcounterpipelineA checkpoint the writer refused because its cursor was older than one the same writer instance already persisted. Always zero under a correct source; non-zero means a source emitted a regressing cursor mid-run.
blockwatcher_in_flight_eventsgaugepipelineHow many dispatched matches are still outstanding (neither delivered nor dead-lettered), sampled once a second and on every checkpoint publish or retry. A value that never drains is the same stall GET /status’s in_flight_events field shows.
blockwatcher_pipelines_aborted_totalcounterpipeline (pipeline only)A pipeline forced through a hard abort during drain: the shutdown deadline was missed and a straggling task had to be cancelled outright, or a restart’s own drain hit the same deadline. Deliberately carries no sink label: an abort is a property of the whole pipeline, not any one delivery.
blockwatcher_pipelines_quiesce_timeout_totalcounterpipeline (pipeline only)A Storage::quiesce call, made after an escalated drain (or re-attempted for a debt an earlier one left owed) to wait out whatever an aborted task’s storage calls left running, that did not resolve within its budget. Always zero under a healthy backend. Counted once at stop_pipeline itself no matter which caller reached it: a restart, a gate-envelope change or a gated monitor’s delete, and a reorg invalidation all refuse in the same shape on this timeout (spawning a replacement, dropping a held journal, or pruning and retracting, respectively, could each race the outstanding write), leaving the network abandoned. A whole-engine shutdown facing the same timeout only warns and counts it, since the process is exiting either way.
blockwatcher_dead_letter_write_failures_totalcounterpipeline, sinkA dead-letter record itself failed to write and is being retried forever. The only signal an exporter has that a sink’s dead-letter storage is stuck: the retry never gives up on its own.
blockwatcher_pipeline_source_restarts_totalcounterpipelineThe restart supervisor actually respawned this pipeline after its source exited before its own cancellation fired.
blockwatcher_pipeline_source_restart_failures_totalcounterpipelineA restart attempt the supervisor made and that itself failed, for a reason other than the engine shutting down. The network is left with no running pipeline and nothing further will retry it. Distinct from blockwatcher_source_invalidation_failures_total, which counts a failed invalidate rather than a failed crash-restart.
blockwatcher_source_invalidations_totalcounterpipelineA source returned Invalidated { from }. Counted once per handled invalidate for that network.
blockwatcher_source_invalidation_failures_totalcounterpipelineA handled invalidate that failed to finish retract or rewind. The network is left with no running pipeline and an unrewound checkpoint until an operator acts. Distinct from blockwatcher_pipeline_source_restart_failures_total.
blockwatcher_retracts_totalcounterpipeline, sinkA Retracted event handed to the one-shot invalidate pass. A successful deliver also increments blockwatcher_deliveries_total.
blockwatcher_journal_gap_totalcounterpipelineThe invalidate cursor plus journal_depth did not cover the checkpoint high-water mark, so some delivered match ids were already pruned. Never silent: also logged at error.
blockwatcher_rewinds_refused_totalcounterpipelineA non-zero rate means an invalidation arrived that no checkpoint write could apply backward: its cursor was not behind the stored checkpoint, no checkpoint was stored at all, or the post-drain checkpoint read failed. Stored state was left as it was (an absent checkpoint is not fabricated) and the pipeline restarted; expect replayed duplicates, never gaps. The common cause is benign: the proven fork sits at the tracker’s own newest emitted block, inside the checkpoint’s own range, so refusing the rewind is the correct outcome. Suspect a stalled sink only when refusals on one network pair with retract or dead-letter anomalies there too.
blockwatcher_operator_actions_totalcounteraction, plus pipeline or monitorA control-surface mutation whose persist has succeeded. action is stable snake_case (pause_monitor, resume_monitor, pause_network, resume_network, discard_dead_letter, discard_dead_letters). Identity is pipeline (network id) or monitor as appropriate. One counter, not one name per action.
blockwatcher_gate_hits_totalcounterpipeline, monitor, gateA timestamped hit offered to on_hit.
blockwatcher_gate_emits_totalcounterpipeline, monitor, gateAn Emit applied (one per mint, not per contained match).
blockwatcher_gate_discards_totalcounterpipeline, monitor, gateA Discard applied.
blockwatcher_gate_untimestamped_totalcounterpipeline, monitor, gatePredicate-true, no usable block.timestamp; not inserted.
blockwatcher_gate_hits_dropped_totalcounterpipeline, monitor, gateOldest hold dropped because of the 10_000 cap.
blockwatcher_gate_persist_failed_totalcounterpipeline, monitor, gateStorage put failed; that cursor is stalled.
blockwatcher_gate_decision_invalid_totalcounterpipeline, monitor, gateon_hit returned a decision the engine rejected; this hit discarded. Should stay at zero.
blockwatcher_gate_journal_heldgaugepipeline, monitor, gateHits currently held, set after every evaluation. Sustained values near gate_hits_cap are the signal to look at gate write volume.

That is every metric blockwatcher-core exports through the metrics facade. A test in metrics.rs pins each constant’s string literal against the contract, so a typo in the definition itself cannot silently drift from what a dashboard expects.

The same events, twice

Each pipeline event forks into two independent destinations, neither derived from the other:

flowchart LR
    event["pipeline event<br/>decode, match, deliver..."] --> atomics["PipelineCounters<br/>plain atomics"]
    event --> facade["metrics facade<br/>named constants"]
    atomics --> status["GET /status<br/>point-in-time JSON"]
    facade --> recorder["prometheus recorder<br/>5s flush"]
    recorder --> scrape["GET /metrics<br/>exposition text"]

Every row above has a twin: crates/blockwatcher-core/src/counters.rs’s PipelineCounters holds one plain atomic per status field (decoded, undecodable, matched, match_errors, delivered, dead_lettered, dispatch_failed, checkpoint_write_failed, dead_letter_write_failed, misrouted, checkpoint_regressions_refused, untimestamped, gate_persist_failed, gate_hits_dropped, gated, gate_emitted); the Prometheus contract additionally exposes in_flight_events, pipelines_aborted, the restart counters, the invalidation-failure counter, the invalidate / retract / journal-gap counters, dead_letters_pruned, and deliveries_throttled and digest_deliveries, which have no per-network JSON counterpart), and both are incremented from the same call site. The atomics feed GET /status’s counters object directly: a point-in-time read, no labels, no cardinality cost, nothing to scrape, while the Prometheus constants feed a time series an alerting rule can take a rate() of. Neither is derived from the other; they are two independent destinations for the same underlying event, which is why the numbers agree at any instant but serve different jobs: reach for /status when debugging one network right now, and for /metrics when watching every network’s trend over time.

Other metrics on this endpoint

blockwatcher-rpc’s connection pool (the load-balancing and circuit-breaking layer evm-rpc and evm-mempool both sit on) publishes its own operational metrics onto the same process-wide recorder, under a blockwatcher_rpc_ prefix (crates/blockwatcher-rpc/src/pool.rs). They are not part of the per-network contract above and carry an endpoint label (the RPC endpoint name from a network’s endpoints[] config) rather than pipeline:

MetricLabelsMeaning
blockwatcher_rpc_attempts_totalendpointOne attempt execute made against an endpoint, whether it succeeded or not.
blockwatcher_rpc_attempt_failures_totalendpoint, classAn attempt that did not succeed; class is the failure’s ErrorClass rendering, or "timeout" for the attempt’s own deadline elapsing.
blockwatcher_rpc_breaker_opened_totalendpointA consecutive-failure streak that tripped that endpoint’s breaker from admitting to open.
blockwatcher_rpc_rate_limit_waits_total(none)An execute iteration that slept because every candidate endpoint was rate-limited: pool-wide, not attributable to one endpoint.
blockwatcher_rpc_exhausted_totalreasonAn execute call that ended with no served value; reason is deadline_exceeded, no_endpoint_available, or pin_unavailable.
blockwatcher_rpc_probe_failures_totalendpointA periodic health probe against an endpoint that did not produce a value.

evm-rpc sources publish metrics of their own under a blockwatcher_evm_ prefix (crates/blockwatcher-evm/src/source/rpc/run.rs); the reorg counter carries pipeline and depth, the skip counter carries only pipeline, and the contradiction and range-split counters carry pipeline and endpoint:

MetricLabelsMeaning
blockwatcher_evm_reorgs_totalpipeline, depthOne linkage break this source classified. depth is within_confirmations (retried in place), beyond_confirmations (proven fork, Invalidated { from }), or beyond_window (no tracked ancestor matched; Invalidated { from } just below the oldest tracked height, bounded by the tracker’s own depth rather than a proven fork block).
blockwatcher_evm_bloom_skips_totalpipelineOne leaf window whose eth_getLogs call was skipped because every fetched header’s logsBloom refuted every monitored address, or every monitored topic0, in the filter: either dimension refuted alone already rules out a match, since eth_getLogs requires both to hold. Counted once the window’s fetch returns, whether or not the window later survives reorg verification and is actually emitted.
blockwatcher_evm_bloom_contradictions_totalpipeline, endpointOne log whose own block’s fetched bloom failed to admit that log’s own address and topic0, proof that the named endpoint’s blooms do not describe the logs it returns. The first one disables bloom_screen for the running source instance that observed it; a nonzero rate afterward on the same running instance means every window since has been fetched in full rather than screened. That disabling does not survive a restart of the pipeline’s source, so it is not a durable fix on its own; see the alerting hint below.
blockwatcher_evm_range_splits_totalpipeline, endpointOne leaf a provider forced out of a wider eth_getLogs range it refused to serve in full. Counted once per leaf (so a window split into three leaves increments this twice, for the two beyond the first), against the endpoint that forced the split.

evm-mempool sources publish metrics of their own, under the same blockwatcher_evm_ prefix (crates/blockwatcher-evm/src/source/mempool/run.rs). The skip counter carries endpoint; the reconnect counter does not, since a lost subscription is not attributable to one endpoint:

MetricLabelsMeaning
blockwatcher_evm_mempool_skips_totalpipeline, endpoint, reasonOne subscription hash that produced no emission for a reason outside this source’s control; reason is gone (the lookup answered null because the transaction was mined or evicted first) or lookup_failed (the pool gave up). Neither is an error: a pending stream is best-effort by nature.
blockwatcher_evm_mempool_reconnects_totalpipeline, reasonOne lost WebSocket subscription this source had to reconnect from; reason is dial_failed, stream_closed, transport_error, or idle_timeout.

Alerting hints

Dead-letter count rising. increase(blockwatcher_dead_letters_total[15m]) > 0 sustained, for one sink label, means that sink is failing permanently or exhausting retries, not a one-off blip. Pull the reason off GET /networks/{id}/dead-letters (each entry’s reason string leads with transient: or permanent:) before deciding whether to fix the sink target or just replay.

A sink’s throttle is swallowing everything. increase(blockwatcher_deliveries_throttled_total[15m]) sustained at roughly the same rate as increase(blockwatcher_matches_total[15m]) for one sink label means that sink’s throttle policy is dead-lettering nearly every match rather than occasionally shedding a burst. Widen max_deliveries or window_ms for that sink, or pause the monitors feeding it, rather than leaving it to accumulate dead letters that need replaying later. This match-for-match comparison only holds for a sink with no aggregate policy: blockwatcher_deliveries_throttled_total counts suppressed matches while blockwatcher_deliveries_total counts a digest as one delivery regardless of how many matches it carried, so for an aggregating sink the throttled-versus-delivered ratio is not match-over-match. Compare against blockwatcher_digest_deliveries_total instead to see how many of that sink’s deliveries were digests in the first place.

Checkpoint lag growing. Watch blockwatcher_in_flight_events for a value that climbs and never comes back down, alongside blockwatcher_checkpoint_write_failures_total ticking upward. The first says a delivery is stuck open (check which sink’s queue is backed up), and the second says storage itself is the problem; either one means the network’s checkpoint has stopped advancing, which is exactly the signal GET /status’s lag field surfaces from the other side.

Threshold never fires. Check blockwatcher_gate_untimestamped_total, whether count is higher than traffic, and whether invalidate drained holds (must not). Compare event timestamps, not wall clock. GET /status pipeline counters gated (Retain+Discard) and gate_emitted (one per Emit) are the point-in-time twins.

Checkpoint stuck and persist_failed ticking. Same class as a closed sink: fix storage; do not skip the event. Watch blockwatcher_gate_persist_failed_total.

Tip reorg “lost” a burst. If holds with cursor ≤ from were dropped, that is a bug (drain-all). Replay will not restore them.

Gate log lines carry network, monitor, and gate (module name).

A network’s source has stopped coming back. blockwatcher_pipeline_source_restart_failures_total > 0 for a pipeline label means the restart supervisor tried and failed to bring that network’s source back up, and, per the metric’s own contract, nothing further will retry it on its own; the pipeline is down until an operator intervenes. This is a stronger signal than an occasional blockwatcher_pipeline_source_restarts_total tick, which just means a source exited and was cleanly respawned. A failed invalidate is a different abandonment: watch blockwatcher_source_invalidation_failures_total for that.

A deep invalidate failed to finish. blockwatcher_source_invalidation_failures_total > 0 for a pipeline label means retract or rewind did not complete. The checkpoint is left unrewound and the source is not restarted; this is not a crash-restart failure. Inspect dead letters for type: retracted payloads, then intervene.

A deep invalidate left a journal gap. increase(blockwatcher_journal_gap_total[15m]) > 0 for a pipeline label means that network’s invalidate cursor plus journal_depth did not cover the checkpoint high-water mark, so some already-delivered match ids cannot be retracted. The matching error log names the network, from, the checkpoint, and the configured depth. Raise journal_depth (it must stay well above confirmation depth) and treat consumer-side undo for ids older than the window as an operator problem: the engine will still rewind and restart.

A source keeps invalidating. increase(blockwatcher_source_invalidations_total[15m]) climbing on one pipeline is unusual for a quiet chain; pair it with blockwatcher_retracts_total to see whether sinks are actually receiving undos. An unrecovered retract leaves the checkpoint unrewound: watch blockwatcher_source_invalidation_failures_total, blockwatcher_dead_letters_total, and in_flight_events on that network rather than expecting a restart.

A reorg past the tracked window. increase(blockwatcher_evm_reorgs_total[15m]) with depth="beyond_window" on one pipeline means no tracked ancestor matched the live chain. The source invalidates from just below the oldest tracked height, a rewind bounded by the tracker’s own depth rather than genesis: the engine retracts journaled deliveries above that bound and restarts the source, the same path as any other invalidation, so expect blockwatcher_source_invalidations_total to tick alongside this depth label.

A bloom-skip rate of zero is not itself a fault. blockwatcher_evm_bloom_skips_total staying flat on a network with a selective monitor (one whose filter names addresses or topic0s) and an active chain is informational, not a fault: it means bloom_screen is disabled for that network, or the merged filter is broad enough (no addresses and no topic0s) that there is nothing left for a header’s bloom to refute. Read it alongside the filter a network’s monitors actually produce before treating it as a signal of anything gone wrong.

A bloom-contradiction count that is not zero. increase(blockwatcher_evm_bloom_contradictions_total[15m]) > 0 for a pipeline and endpoint pair means that endpoint served a header bloom that failed to admit a log it returned for the same block, which can only happen when its blooms do not describe its own logs. Two things follow, and neither is automatic:

  • The running source instance that observed the contradiction has already disabled bloom_screen for itself, so no further window on that particular instance is at risk. That protection belongs to the instance, not the endpoint or the process: a restart of that pipeline’s source (the supervisor recovering from an exit, a proven-reorg invalidation, or a monitor change that escalates to a restart) builds a fresh instance with screening enabled again, against the same endpoint that already proved its blooms unreliable. Set bloom_screen = false for that network to make the disabling survive a restart; nothing else does.
  • The contradiction proves the endpoint’s blooms are wrong, but says nothing about when the endpoint started serving wrong ones. Every window this source screened earlier in its run rested on the same untrustworthy blooms; the contradiction is only the first one this source happened to catch because a later window’s own headers still forced a real fetch. Read blockwatcher_evm_bloom_skips_total for the same pipeline to see how many windows were screened before the trip, and re-scan that range through POST /monitors/{id}/test’s fetch mode (which never screens) or a fresh network with start_block covering it: the checkpoint has already advanced past those windows, and nothing rewinds them on its own.

Read the matching warning log for the network, endpoint, the contradicting window’s own block range, the first contradicting block, and the contradiction count, and treat that endpoint’s blooms as untrusted for any other purpose too.

An evm-mempool connection is flapping. increase(blockwatcher_evm_mempool_reconnects_total[15m]) > N for a pipeline label means that network’s WebSocket subscription keeps dropping and reconnecting; GET /status’s source.status only ever shows whichever state (live, degraded, catching_up) is current at scrape time, so a short-lived flap between two scrapes is invisible there even though it loses whatever was pending in the node’s mempool during the gap.

Running with Docker

docker/ ships a two-service Compose stack (blockwatcher itself, plus a companion web UI), meant as a runnable local demo, not a production manifest. It has its own instance config, its own Dockerfiles, and its own .env, and it seeds the sink-script-monitor example on first boot: a usdc-script-test monitor watching USDC Transfer events on Sepolia, delivering every match to the dashboard’s ui-ingest webhook and to a notify-script script sink that appends one line per match to /data/script-sink-events.log inside the blockwatcher container.

The stack: docker/compose.yaml

services:
  blockwatcher:
    build:
      context: ..
      dockerfile: docker/Dockerfile.blockwatcher
    command: ["--config", "/etc/blockwatcher/blockwatcher.toml", "--seed", "/opt/script-sink/resources"]
    env_file:
      - .env
    environment:
      UI_INGEST_URL: http://ui:8080/ingest
      UI_INGEST_SECRET: ${UI_INGEST_SECRET:?set it in docker/.env}
    volumes:
      - ./blockwatcher.toml:/etc/blockwatcher/blockwatcher.toml:ro
      - ../examples/sink-script-monitor:/opt/script-sink:ro
      - blockwatcher-data:/data
    healthcheck:
      test: ["CMD", "curl", "-fsS", "http://localhost:8080/health"]
      interval: 5s
      timeout: 3s
      retries: 30
    restart: unless-stopped

  ui:
    build:
      context: ..
      dockerfile: docker/Dockerfile.ui
    environment:
      BLOCKWATCHER_API_URL: http://blockwatcher:8080
      BLOCKWATCHER_API_TOKEN: ${BLOCKWATCHER_API_TOKEN:?set it in docker/.env}
      UI_OPERATOR_SECRET: ${UI_OPERATOR_SECRET:?set it in docker/.env}
      UI_INGEST_SECRET: ${UI_INGEST_SECRET:?set it in docker/.env}
      RUST_LOG: info
      UI_ALLOWED_HOSTS: "127.0.0.1,localhost,[::1],ui:8080"
    ports:
      - "127.0.0.1:8080:8080"
    volumes:
      - ui-data:/data
    depends_on:
      blockwatcher:
        condition: service_healthy
    restart: unless-stopped

volumes:
  blockwatcher-data:
  ui-data:

The container and volume topology (a different picture from The dashboard’s application-level request flow, which this stack’s ui service is the one running) looks like this:

flowchart LR
    subgraph blockwatcher_c["blockwatcher service, no published port"]
        blockwatcherbin["blockwatcher binary"]
        blockwatchervol[("blockwatcher-data<br/>/data/blockwatcher.db<br/>/data/script-sink-events.log")]
    end
    subgraph ui_c["ui service, 127.0.0.1:8080"]
        uibin["ui server"]
        uivol[("ui-data<br/>/data/ui.db")]
    end

    blockwatcher_c -->|"depends_on:<br/>service_healthy"| ui_c
    ui_c -->|"REST proxy<br/>to blockwatcher:8080"| blockwatcher_c
    blockwatcher_c -->|"webhook POST<br/>to ui:8080/ingest"| ui_c

Both services build from the repository root (context: ..) so each Dockerfile can COPY whichever part of the workspace it needs: the blockwatcher service’s build only touches crates/; the ui service’s build touches ui/.

blockwatcher boots with --config /etc/blockwatcher/blockwatcher.toml (the file bind-mounted read-only from ./blockwatcher.toml) and --seed /opt/script-sink/resources, the example’s resource directory bind-mounted read-only from ../examples/sink-script-monitor (what that seed loads, and when, is covered below). env_file: .env is where BLOCKWATCHER_API_TOKEN comes from; UI_INGEST_URL is a value blockwatcher itself never reads at boot: it’s resolved later, at delivery time, once the ui service asks blockwatcher to create a sink. Its healthcheck curls its own /health (the one route the HTTP API serves without a bearer token), five seconds apart, up to 30 times, which is what ui’s depends_on: condition: service_healthy waits on before starting. blockwatcher-data is the one persistent volume: it holds /data/blockwatcher.db, the SQLite store named in blockwatcher.toml below, so pipeline state, resources, and checkpoints survive a docker compose restart or a rebuild. Note there is no ports: entry for this service at all: its API is reachable from ui over the compose network at http://blockwatcher:8080, but not published to the host; reaching it directly from outside the stack means adding a port mapping yourself or running docker compose exec blockwatcher ….

ui is the only service exposed to the host, and only on the loopback interface (127.0.0.1:8080:8080, not 0.0.0.0). BLOCKWATCHER_API_TOKEN, UI_OPERATOR_SECRET, and UI_INGEST_SECRET are required at compose-parse time (:?set it in docker/.env); the last two must not be the same value.

UI_ALLOWED_HOSTS and UI_INGEST_URL work together, and neither makes sense read alone. On its own first boot (ui/server/src/main.rs), the ui service calls blockwatcher’s API to create a webhook sink named ui-ingest, whose url_secret names UI_INGEST_URL (a reference blockwatcher resolves in its own environment, not the UI’s), which is exactly why the blockwatcher service (not ui) is the one carrying that variable above. From then on, every match any monitor produces that names ui-ingest among its actions gets delivered as a webhook request from the blockwatcher container straight into ui, addressed by compose service name and arriving with Host: ui:8080 (a header this companion checks against an allowlist that defaults to loopback names only) (ui/server/src/guard.rs, ui/server/src/config.rs). Ingest also presents x-blockwatcher-ingest from UI_INGEST_SECRET; operators reach /api with a session from UI_OPERATOR_SECRET. The Host/Origin guard is what keeps a DNS-rebinding page from looking like this host. Without UI_ALLOWED_HOSTS naming ui:8080 explicitly, that legitimate ingest call would be indistinguishable from an attack and rejected identically. ui-data is this service’s own persistent volume, separate from blockwatcher’s: the two services never share a data directory.

The two Dockerfiles

docker/Dockerfile.blockwatcher: a two-stage build. The first stage (rust:1.88-bookworm) copies just Cargo.toml, Cargo.lock, and crates/, then cargo build --release -p blockwatcher: no ui/ in this stage’s build context, since the binary crate doesn’t need it. The second stage (debian:bookworm-slim) installs only ca-certificates (for outbound TLS to an RPC provider) and curl (for the healthcheck above), copies the release binary in, and sets it as ENTRYPOINT. Two stages, not one, is what keeps the shipped image free of the Rust toolchain and the crate source it was built from.

docker/Dockerfile.ui: three stages, because the UI is two components. node:22-bookworm builds the web assets (npm ci, npm run build) from ui/web; a second, independent rust:1.88-bookworm stage builds ui/server; the final debian:bookworm-slim stage copies the server binary and the web build’s dist/ output into one image, EXPOSEs 8080, and sets three environment defaults (UI_STATIC_DIR=/app/static, UI_BIND=0.0.0.0:8080, UI_DB_PATH=/data/ui.db) that the compose file above never overrides, so they’re exactly what the running container uses.

The same two Dockerfiles are what a vX.Y.Z tag pushes to GHCR as ghcr.io/thethirdorigin/blockwatcher and ghcr.io/thethirdorigin/blockwatcher-ui, both tagged with the workspace version. The UI image tag is the release it shipped with, not ui/server’s own crate version.

docker/blockwatcher.toml: the containerized instance config

[api]
enabled = true
listen = "0.0.0.0:8080"

[[auth.tokens]]
label = "compose"
scope = "admin"
secret = "env:BLOCKWATCHER_API_TOKEN"

[storage]
module = "sqlite"
config = { path = "/data/blockwatcher.db" }

Two differences from the annotated example on the Configuration reference page are worth calling out. First, [api].listen binds 0.0.0.0, not 127.0.0.1: correct inside a container, where “loopback” means only the container’s own network namespace and would make the ui service’s cross-container call unreachable; the compose file is what keeps this from being exposed carelessly, since blockwatcher publishes no host port at all. Second, there is no [metrics] section here, so the Prometheus scrape endpoint described on the Observability page stays at its default (disabled) in this stack; enabling it means adding the section to this file and, if it should be reachable from outside the container, a ports: mapping in compose.yaml to go with it. There is also no [engine] section, so every engine tunable (channel capacities, drain deadline, retry policy, the matcher module) runs at whatever blockwatcher-core’s own defaults are. docker/ itself carries no resources/ tree: the network, spec, sink, and monitor JSON the stack seeds is examples/sink-script-monitor’s, bind-mounted in, and once the first boot has loaded it the ui service is how those four resource kinds are created and edited against the running blockwatcher API.

Seeding under compose

The command: in compose.yaml passes --seed /opt/script-sink/resources, the read-only mount of examples/sink-script-monitor/resources: one Sepolia network, the USDC ERC-20 spec, the ui-ingest and notify-script sinks, and the usdc-script-test monitor wiring them together. --seed is a one-time, first-boot-only load (it never touches a store that already holds anything, see Seed), so the seed lands exactly once: every later docker compose up boots with whatever blockwatcher-data’s SQLite file already holds, and the running instance is authoritative from then on. A fresh seed therefore takes a docker compose down -v first, which discards both data volumes. To seed a different resource directory instead, point the bind mount (and the --seed path) at it before the first boot, or POST resources through the ui service’s API-proxying UI once it is healthy.

Running it

cp docker/.env.example docker/.env

Edit docker/.env and set BLOCKWATCHER_API_TOKEN to any string, UI_OPERATOR_SECRET and UI_INGEST_SECRET to two different strings (compose interpolates all three with :?set it in docker/.env, and the companion refuses to boot if the last two are equal), and SEPOLIA_RPC_URL to an RPC endpoint, the variable the seeded network’s url_secret names. Any other url_secret a resource names later has to resolve from this same .env too, since env_file puts every variable in it into the blockwatcher container’s environment whether blockwatcher itself defines it or not.

Then stamp a recent start block into the seeded network resource (the seed’s start_block is absolute, and a stale one replays history):

./examples/sink-script-monitor/setup.sh
docker compose -f docker/compose.yaml up --build -d

The dashboard becomes reachable at http://127.0.0.1:8080 once its healthcheck dependency on blockwatcher passes, and matches appear there as Sepolia produces USDC transfers. The script sink’s copy of the same matches is a log inside the blockwatcher container:

docker compose -f docker/compose.yaml exec blockwatcher \
  tail -f /data/script-sink-events.log

Each line carries the full sink event as raw={...} JSON; the README’s Docker walkthrough carries a jq filter that unpacks it into the transfer’s interesting fields. docker compose down -v removes both named volumes along with the containers (and re-arms the first-boot seed); drop -v to keep blockwatcher-data/ui-data across a teardown.

Monitoring ERC-20 transfers [RPC]

Your first monitor already walked examples/source-rpc-monitor/ end to end: running it, reading the output, narrowing a predicate through the API. This page takes a different pass at the same directory: every file, reproduced in full, annotated field by field, as a reference for what each line actually does rather than a “do this next” narrative. Read that page first if you haven’t run the example yet; read this one when you want to know exactly what you’re looking at.

source-rpc-monitor/
├── .env.example
├── blockwatcher.toml
├── setup.sh
└── resources/
    ├── networks/sepolia.json
    ├── specs/usdc-erc20.json
    ├── sinks/log-sink.json
    └── monitors/usdc-sepolia-transfers.json

.env.example

SEPOLIA_RPC_URL=https://sepolia.infura.io/v3/YOUR_API_KEY
BLOCKWATCHER_API_TOKEN=local-test-token

Two variables, both required. Nothing under resources/ ever holds SEPOLIA_RPC_URL’s actual value: resources/networks/sepolia.json below names the variable itself, and blockwatcher resolves it from its own process environment the moment it needs it. That indirection exists because the URL usually is the credential: most providers put the API key somewhere in the path or query string, so the file that would otherwise carry it stays out of the resource entirely and out of anything that might get committed. BLOCKWATCHER_API_TOKEN can be any string here: the example’s blockwatcher.toml binds the API to 127.0.0.1 only, so there is no network exposure to defend against, just a token the example’s own curl commands need to match.

blockwatcher.toml

[api]
enabled = true
listen = "127.0.0.1:8080"

[[auth.tokens]]
label = "operator"
scope = "admin"
secret = "env:BLOCKWATCHER_API_TOKEN"

[metrics]
enabled = true
listen = "127.0.0.1:9090"

[storage]
module = "sqlite"
config = { path = "blockwatcher.db" }

[engine]
event_channel_capacity = 1000
sink_channel_capacity = 100
drain_deadline_ms = 5000
matcher = { module = "expr", config = {} }

Every section here is covered generally on the Configuration reference; what’s worth noting about this instance of it: [metrics] is turned on (unlike the Docker stack’s config, which leaves it off), so curl localhost:9090/metrics works alongside the API without any extra setup (see Observability for what’s on that endpoint). [storage].config.path is a bare relative filename, blockwatcher.db, so the database lands next to wherever the process’s current directory is when it starts: inside examples/source-rpc-monitor/ if you cd there first, per the running instructions. [engine]’s three explicit values (event_channel_capacity, sink_channel_capacity, drain_deadline_ms) each differ from EngineConfig’s own defaults (256/64/10000 respectively, see Configuration reference); an empty [engine] section would boot with those defaults instead.

setup.sh

#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")"

[ -f .env ] || { echo "error: no .env — run: cp .env.example .env, then edit it" >&2; exit 1; }
set -a; . ./.env; set +a

: "${SEPOLIA_RPC_URL:?error: SEPOLIA_RPC_URL is unset in .env}"
case "$SEPOLIA_RPC_URL" in
  *YOUR_API_KEY*) echo "error: SEPOLIA_RPC_URL still holds the placeholder YOUR_API_KEY" >&2; exit 1 ;;
  http://*|https://*) ;;
  *) echo "error: SEPOLIA_RPC_URL must be an http(s) URL, not a ${SEPOLIA_RPC_URL%%:*}: one" >&2; exit 1 ;;
esac

head_hex=$(curl -fsS "$SEPOLIA_RPC_URL" \
  -H 'Content-Type: application/json' \
  -d '{"jsonrpc":"2.0","method":"eth_blockNumber","params":[],"id":1}' \
  | jq -re '.result') || { echo "error: the endpoint did not answer eth_blockNumber" >&2; exit 1; }

start=$(( head_hex - 20 ))

net=resources/networks/sepolia.json
tmp=$(mktemp)
jq --argjson b "$start" '.source.config.start_block = $b' "$net" > "$tmp" && mv "$tmp" "$net"

(Trimmed of its own comments and echo lines above, the repository copy explains its own reasoning inline.) The script is three checks and one mutation, in order: a .env file exists at all; the RPC URL is set, isn’t still the literal placeholder, and has a scheme that’s actually http(s) rather than, say, a stray ws:// pasted from the wrong example; and a live eth_blockNumber call succeeds against it, subtracted by 20 blocks and written into sepolia.json’s source.config.start_block in place, using jq and a temp file so a crash mid-write can never leave the resource file truncated. Nothing about this is required for blockwatcher itself to run: it’s a convenience so a fresh clone doesn’t need a human to look up a current Sepolia block number by hand. Running the network file with its original start_block: 11424310 still works if that block hasn’t rolled out of the RPC provider’s retained history; it just costs a longer catching_up wait if 11424310 is very old by the time you try it.

resources/networks/sepolia.json

{
  "id": "sepolia",
  "chain": "evm",
  "source": {
    "module": "evm-rpc",
    "config": {
      "start_block": 11424310,
      "endpoints": [
        {
          "name": "primary",
          "url_secret": "env:SEPOLIA_RPC_URL",
          "priority": "high",
          "rate_limit": { "rps": 10 }
        }
      ],
      "confirmations": 12,
      "max_lag_blocks": 100,
      "poll_interval_ms": 3000,
      "logs_window": { "initial": 1000, "max": 5000 },
      "probe_interval_ms": 30000
    }
  }
}

chain: "evm" selects the evm decoder family; source.module: "evm-rpc" selects confirmed-block scanning over evm-mempool‘s pending stream (see Selectors § The source for what that choice determines about what a selector here can ever match). endpoints is a pool of one: name is this instance’s own label for it (shows up in the blockwatcher_rpc_* metrics’ endpoint label, per Observability); priority: "high" matters once a second endpoint with a lower priority is added: the pool prefers higher-priority candidates and only falls back when they’re rate-limited or breaker-open; rate_limit.rps: 10 caps outbound calls to a level a free-tier provider key tolerates. confirmations: 12 is how many blocks must sit on top of one before its logs are trusted (deeper than the testnet’s own instant-final default, a realistic value for demonstrating a real reorg-safety margin rather than the bare minimum). max_lag_blocks: 100 is the threshold past which source.status reports catching_up instead of live. poll_interval_ms: 3000 is how often the source checks for a new head. logs_window governs how many blocks one eth_getLogs call spans while catching up: starting at 1000 and growing to 5000 as the gap narrows. None of these seven tuning keys are required: every one has its own default, and the quickstart’s network file carries none of them, but each is set here to a number an operator running against a real testnet would actually pick, rather than a value chosen only to demonstrate that the field exists.

resources/specs/usdc-erc20.json

{
  "id": "usdc-erc20",
  "chain": "evm",
  "payload": [
    {
      "type": "event",
      "name": "Transfer",
      "anonymous": false,
      "inputs": [
        { "name": "from",  "type": "address", "indexed": true },
        { "name": "to",    "type": "address", "indexed": true },
        { "name": "value", "type": "uint256", "indexed": false }
      ]
    },
    {
      "type": "event",
      "name": "Approval",
      "anonymous": false,
      "inputs": [
        { "name": "owner",   "type": "address", "indexed": true },
        { "name": "spender", "type": "address", "indexed": true },
        { "name": "value",   "type": "uint256", "indexed": false }
      ]
    }
  ]
}

This is a Solidity ABI fragment list: the raw artifact evm’s decoder compiles once, at write time, into the schema a selector and predicate actually work against. indexed: true on from/to/owner/spender means those parameters live in the log’s topics, not its data; value is indexed: false because ERC-20’s Transfer/Approval standard puts the transferred or approved amount in the log’s data word instead (a deliberate ABI design choice this fragment merely records, not something blockwatcher infers). anonymous: false on both means each event keeps its normal topic0 event signature hash, which is what the decoder actually dispatches on. Only Transfer is ever selected by this example’s monitor; Approval sits in the spec unused, because nothing requires a spec to carry only what one particular monitor asks for: a second monitor on the same network watching approvals could reference this same spec file without it changing at all.

resources/sinks/log-sink.json

{
  "id": "log-sink",
  "module": "log",
  "config": {},
  "retry": {
    "max_attempts": 1,
    "initial_backoff_ms": 100,
    "max_backoff_ms": 1000
  }
}

module: "log" puts each match on the process’s own stdout as a single line of JSON, the shipped sink with the least that can go wrong at delivery time, since there’s no network call and no credential to resolve. config: {} because the log sink takes no configuration at all. retry.max_attempts: 1 means exactly one delivery attempt: on failure (realistically, only a broken stdout pipe) the match dead-letters immediately rather than retrying a failure mode backoff can’t fix. initial_backoff_ms/max_backoff_ms are present but never exercised at max_attempts: 1: they’d matter only if this sink retried, which it doesn’t.

resources/monitors/usdc-sepolia-transfers.json

{
  "id": "usdc-sepolia-transfers",
  "network": "sepolia",
  "selectors": [
    {
      "addresses": ["0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238"],
      "spec": "usdc-erc20",
      "events": ["Transfer"]
    }
  ],
  "predicate": "args.value > 0",
  "actions": ["log-sink"]
}

network: "sepolia" ties this monitor to the network resource above: one network, always, per monitor. The one selector entry restricts to a single contract address (Sepolia’s USDC), names usdc-erc20 as the spec to decode against, and lists events: ["Transfer"]: with events present and functions absent, selector compilation fills only the events table with that one name and leaves the functions table empty; Approval, though declared in the spec, is never decoded by this selector because it was never named. In English, the predicate args.value > 0 reads “keep every transfer that moved a nonzero amount” , which in practice is nearly every one, since a zero-value Transfer is legal ABI-wise but rare in the wild; it exists mainly to show that a predicate is present and doing something, not to meaningfully filter this particular feed. actions: ["log-sink"] sends every match to the one sink above; a monitor with more sinks in its actions list would fan the same match out to each of them independently.

Variations

Watch a different contract. The spec is generic ERC-20 (nothing in usdc-erc20.json is USDC-specific), so pointing at a different token on Sepolia only means a different addresses entry on the monitor (or a second monitor entirely, if you want both watched at once). PUT the existing monitor with a new address and a fresh If-Match:

export TOKEN=$BLOCKWATCHER_API_TOKEN

etag=$(curl -s -o /dev/null -w '%header{etag}' \
  localhost:8080/monitors/usdc-sepolia-transfers -H "Authorization: Bearer $TOKEN")

curl -s -X PUT localhost:8080/monitors/usdc-sepolia-transfers \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -H "If-Match: $etag" \
  -d '{
    "id": "usdc-sepolia-transfers",
    "network": "sepolia",
    "selectors": [{
      "addresses": ["0xfFf9976782d46CC05630D1f6eBAb18b2324d6B14"],
      "spec": "usdc-erc20",
      "events": ["Transfer"]
    }],
    "predicate": "args.value > 0",
    "actions": ["log-sink"]
  }'

(0xfFf9… is Sepolia’s canonical WETH9: any ERC-20 deployment works the same way.)

A predicate with more than one clause. Your first monitor already showed a single threshold; the predicate language supports combining conditions with &&. To keep only large transfers that don’t originate from a specific address (say, a known exchange hot wallet you want to exclude from the feed):

args.value > 500e6 && args.from != 0xF977814e90dA44bFA03b6295A0616a897441aceC

Both operands type-check against the schema usdc-erc20 compiles: args.value and args.from are both Transfer’s own declared parameters (value a uint256, from an address). Excluding by sender has to go through args.from here, not tx.from: a log-decoded occurrence never carries tx.from/tx.to/tx.value at all (see the selectors comparison table), since nothing in a log’s own envelope holds them; only a functions selector, decoding calldata rather than a log, has a tx.from to read.

Deliver to a webhook instead of the log sink. Add a second sink resource using the webhook module (its url_secret follows the same env:NAME indirection as the network’s RPC URL above), then point the monitor’s actions at it instead of (or alongside) log-sink:

{
  "id": "ops-webhook",
  "module": "webhook",
  "config": {
    "url_secret": "env:OPS_WEBHOOK_URL",
    "headers": { "X-Source": "blockwatcher-source-rpc-monitor" },
    "timeout_ms": 10000
  },
  "retry": { "max_attempts": 5, "initial_backoff_ms": 200, "max_backoff_ms": 30000 }
}

PUT it to /sinks/ops-webhook, export OPS_WEBHOOK_URL in the same shell blockwatcher runs in, then PUT the monitor again with "actions": ["ops-webhook"]. A higher max_attempts than the log sink’s 1 makes sense here: an HTTP endpoint has real transient failure modes (a momentary 503, a timeout) that a retry can actually recover from, unlike a broken stdout pipe.

Monitoring ERC-20 transfers [Mempool]

examples/source-mempool-monitor/ is the evm-mempool twin of examples/source-rpc-monitor: same contract, same usdc-erc20 spec file, same sink shape: watching pending calls to USDC’s transfer function instead of confirmed Transfer events. This page gives it the same file-by-file tour, but leads with what’s different, because what’s different here isn’t cosmetic.

What’s different from watching confirmed events

evm-mempool trades completeness for latency: every fact below is already established in more depth on Selectors § The position problem and Delivery guarantees § The evm-mempool exception; this is the short version an operator needs before running this example:

  • The cursor is a per-run arrival counter, not a chain position. There is no start_block to stamp (a pending transaction has no place in the chain yet), and a restart’s fresh checkpoint starts the counter over, so it can never be replayed across a process boundary.
  • Dedupe on the transaction’s own hash, never on the match id. The same pending call seen again after a restart, or even twice within one run if it gets mined mid-hydration, is minted a different arrival number and therefore a different id.
  • tx.status and every block.* field are always absent. Nothing here has a receipt or a mined block to read them from.
  • tx.index is usually absent, but not always. A call can get mined between the pending-tx notification and the hydration lookup that fetches its full data: the copy that comes back then carries a real transactionIndex it didn’t have a moment earlier.
  • This is why this source falls outside at-least-once delivery. A crash can lose whatever was pending during the gap; nothing resumes it.
  • Resuming replays nothing: there’s no history behind a paused mempool feed to catch up on, unlike a paused evm-rpc network.

.env.example

SEPOLIA_WS_URL=wss://sepolia.infura.io/ws/v3/YOUR_API_KEY
SEPOLIA_RPC_URL=https://sepolia.infura.io/v3/YOUR_API_KEY
BLOCKWATCHER_API_TOKEN=local-test-token

Three variables where the confirmed-events example needed two. The new one, SEPOLIA_WS_URL, is what newPendingTransactions is subscribed over: it must be ws:///wss://, never the http(s) scheme SEPOLIA_RPC_URL uses. The two are usually the same provider project’s websocket and HTTP endpoints; SEPOLIA_RPC_URL here does a different job than in the confirmed-events example: not scanning blocks, but hydrating each pending hash the subscription reports into a full transaction via eth_getTransactionByHash. That lookup happens for every hash the subscription hands over, ahead of anything the monitor’s selector or predicate would otherwise rule out. So spend tracks the chain’s overall mempool arrival rate, not how many of those pending calls end up matching this example’s monitor.

blockwatcher.toml

[api]
enabled = true
listen = "127.0.0.1:8080"

[[auth.tokens]]
label = "operator"
scope = "admin"
secret = "env:BLOCKWATCHER_API_TOKEN"

[metrics]
enabled = true
listen = "127.0.0.1:9090"

[storage]
module = "sqlite"
config = { path = "blockwatcher.db" }

[engine]
event_channel_capacity = 1000
sink_channel_capacity = 100
drain_deadline_ms = 5000
matcher = { module = "expr", config = {} }

Identical, key for key, to source-rpc-monitor’s instance config: the [api]/[metrics]/[storage]/[engine] sections have nothing evm-mempool-specific about them; every difference between the two examples lives in the network resource below, not in the process’s own plumbing.

setup.sh

Unlike the confirmed-events example’s setup.sh, there is no start_block to compute or stamp here: nothing pending has a chain position for a script to look up. What it does instead: confirm .env exists and both URLs are set, confirm SEPOLIA_WS_URL actually uses a ws/wss scheme and SEPOLIA_RPC_URL an http/https one (catching the two swapped, a URL still holding the YOUR_API_KEY placeholder, or either one obviously mistyped), and make one real eth_blockNumber call against the HTTP endpoint to prove it answers. It cannot exercise the WebSocket endpoint itself, since only blockwatcher’s own boot ever dials that one, so the script’s own final message says as much rather than implying a check it didn’t perform.

resources/networks/sepolia-mempool.json

{
  "id": "sepolia-mempool",
  "chain": "evm",
  "source": {
    "module": "evm-mempool",
    "config": {
      "ws_url_secret": "env:SEPOLIA_WS_URL",
      "endpoints": [
        {
          "name": "primary",
          "url_secret": "env:SEPOLIA_RPC_URL",
          "priority": "high",
          "rate_limit": { "rps": 10 }
        }
      ],
      "reconnect_ms": 1000,
      "idle_policy": {
        "ping_after_ms": 30000,
        "pong_deadline_ms": 10000
      }
    }
  }
}

source.module: "evm-mempool" is the one field that changes which raw material a selector on this network can ever see. See Selectors § The source. ws_url_secret names the subscription endpoint, resolved the same env:NAME way as every other secret reference in this config; endpoints is the same EndpointDef pool shape evm-rpc uses, here doing hydration calls rather than log/block scans. Three keys have no counterpart in the confirmed-events example’s network config at all: reconnect_ms is the delay before redialing after the WebSocket connection drops; idle_policy.ping_after_ms is how long the connection can go without a frame before blockwatcher sends its own ping to check it’s still alive; idle_policy.pong_deadline_ms is how long it then waits for the pong before declaring the connection dead and reconnecting. When any of the three losses above happens, GET /status reports source.status: degraded with a reason string that’s the full, human-readable error (e.g. no frame arrived within 10s of an idle ping; the connection is presumed half-open for a pong that never came, per crates/blockwatcher-evm/src/ws.rs), not a short code; SourceStatusView passes that string through verbatim (crates/blockwatcher-core/src/status.rs). The short codes dial_failed/stream_closed/transport_error/idle_timeout exist on a different surface entirely: they’re the reason label on the blockwatcher_evm_mempool_reconnects_total counter (and the matching field in this source’s own tracing logs), one increment per subscription lost. That’s the surface to alert on for a flapping connection, since a status snapshot only ever shows whichever state is current, and a reconnect that lands before the next scrape reads as healthy either way. Absent from this file entirely, and refused if added: start_block. There is no chain position for a pending transaction to resume from, so the config has nothing to name.

resources/specs/usdc-erc20.json

Same id, same two events as the confirmed-events example’s spec, plus three function fragments this example actually uses:

{
  "type": "function",
  "name": "transfer",
  "stateMutability": "nonpayable",
  "inputs": [
    { "name": "to", "type": "address" },
    { "name": "amount", "type": "uint256" }
  ],
  "outputs": [ { "name": "", "type": "bool" } ]
}

(approve and transferFrom follow the same shape in the actual file, with their own parameter lists.) A function fragment’s inputs are its call arguments, decoded from the transaction’s calldata rather than a log’s topics or data, which is why functions selectors have nothing to do with indexed, a concept that only means something for an event’s log encoding. stateMutability and outputs are recorded from the ABI but don’t affect decoding or matching; only inputs, alongside the function’s own name (hashed into its first-four-bytes selector), matter to what a functions selector can dispatch on. This spec is not the same file as the confirmed-events example’s: it’s a separate copy under this example’s own resources/, extended with the three function fragments above that copy doesn’t carry, but it keeps the same id and the same two event fragments unchanged. That’s the pattern worth reaching for whenever you want both a confirmed audit trail and an early pending-call signal off the same contract: one evm-rpc network and one evm-mempool network, each with its own copy of a spec that started identical and only grows the fragments the mempool side actually needs, rather than each deployment maintaining two divergent ABI descriptions of the same contract from scratch.

resources/monitors/usdc-sepolia-pending-transfers.json

{
  "id": "usdc-sepolia-pending-transfers",
  "network": "sepolia-mempool",
  "selectors": [
    {
      "addresses": ["0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238"],
      "spec": "usdc-erc20",
      "functions": ["transfer"]
    }
  ],
  "predicate": "args.amount > 0",
  "actions": ["log-sink"]
}

The one selector-body difference from the confirmed-events monitor: functions: ["transfer"] where that one had events: ["Transfer"]. This isn’t a style choice: an events selector would still compile cleanly against this network, since compilation only ever validates a selector against its spec, never against which source module the network happens to run, but it could never produce a single match here: evm-mempool never emits a log at any point in its lifetime for such a selector to catch. args.amount here is transfer’s own second parameter (named amount, not value, because that’s what this ABI fragment calls it); it is unrelated to args.value, the Transfer event’s third parameter the other example’s predicate reads. Both predicates read as “keep every nonzero transfer,” just against two different decoded shapes.

resources/sinks/log-sink.json

Identical to the confirmed-events example’s, with the same module, same single-attempt retry policy, and the same reasoning: nothing about the log sink’s own behavior changes based on which source fed it a match.

When mempool watching is worth it

This source earns its place when the thing you care about is someone tried to call this function, and minutes or even seconds of latency change the value of knowing: flagging a large pending transfer before it’s mined, watching for a specific address’s activity the moment it hits a node’s mempool, anything where “usually right, occasionally wrong, but fast” beats “always right, but a block or twelve confirmations later.” It earns its place a lot less (arguably not at all) anywhere the record has to be complete or auditable: billing off matched events, anything feeding a ledger, anything where a transaction that gets dropped or replaced after this source already delivered it would leave a consumer holding a phantom event with no retraction ever coming. The evm-rpc twin of this example is the one to reach for whenever that completeness matters more than the head start.

Variations

Widen the functions watched. The spec already declares approve and transferFrom alongside transfer; add them to the selector’s functions list to catch pending approvals and delegated transfers too:

"functions": ["transfer", "approve", "transferFrom"]

Exclude a known address instead of just thresholding the amount. args.amount > 0 only rules out the zero-amount edge case; combine it with tx.from (always present on a functions selector, mined or not) to drop calls from an address you already know about (say, a market maker’s hot wallet that transfers constantly and would otherwise dominate the feed):

args.amount > 1_000e6 && tx.from != 0xF977814e90dA44bFA03b6295A0616a897441aceC

Split the two endpoints across different provider projects. ws_url_secret and endpoints[].url_secret are independent secret references: nothing requires them to name variables from the same provider account. Pointing the subscription at one provider and the hydration pool at another spreads load and removes a single provider outage as a way to lose the feed entirely; it costs nothing but a second env:NAME variable and a second free-tier key.

Troubleshooting

A symptom-first index into failure modes that are either expected behavior worth recognizing, or genuine problems with a specific fix. Every quoted string below is copied from the code that produces it, not paraphrased, so it’s safe to grep the same text against your own logs.

SymptomDiagnosisFix
Every API call answers 401 with {"error":{"code":"unauthorized","message":"a bearer token is required"}}No Authorization header, a malformed one, or a token that doesn’t match any row of [auth]. Checked by require_bearer (crates/blockwatcher-api/src/auth/mod.rs). GET /health is the exemption.Send Authorization: Bearer <token>. Confirm the env:NAME variable that row’s secret names is set in the process’s environment: it’s read at request time, not cached, so a variable exported after boot still works, but an unset one fails every request identically.
Every mutating API call answers 403The token authenticated but its scope is below that MethodRouter’s minimum (read cannot PUT a sink). Distinct from 401.Use a token whose scope is at least operate or admin as the route table requires.
A resource write answers 422 invalid_resource naming an unknown field '…' or unknown event/function '…'A typo in a predicate field path or a selector’s events/functions name. Predicate field lookups get an edit-distance suggestion (unknown field 'args.vlaue' — did you mean 'args.value'?', the suggestion helpers and field_error in crates/blockwatcher-expr/src/typecheck/diagnostics.rs); an unknown selector name gets the spec’s first declared name of that kind instead (kind_suggestion in crates/blockwatcher-evm/src/decoder/selector.rs). Both port errors share one message shape: unknown field '{field}'{ — did you mean '{s}'?} (crates/blockwatcher-ports/src/error.rs:89,112).Read the suggestion: it’s often exactly right. If not, confirm the real spelling with GET /specs/{id}/schema, which reflects the spec’s compiled schema, not the raw ABI text.
A monitor never produces a match: decoded/matched stay at 0 in GET /status’s countersFour independent causes look identical from the outside: the monitor (or its network) is paused; the selector names the wrong kind for its source (an events selector on an evm-mempool network can never match; see Selectors); the predicate is stricter than intended and every occurrence resolves Unknown/False; or, on evm-rpc, bloom_screen has skipped eth_getLogs believing every block in a window empty (see the row below). A paused monitor is excluded from decode interest entirely, in build_monitor_set (crates/blockwatcher-core/src/compile.rs), so it shows as decoded: 0 exactly like a monitor that’s simply seeing nothing yet.Check source.status on GET /status first: paused there is the control-plane pause view, distinct from a source genuinely having nothing to report. Confirm the selector kind matches what the network’s source module actually produces. Temporarily drop the predicate field (absent matches everything a selector decodes) and see whether matched starts climbing; if it does, the predicate was the filter.
No matches on a network whose blocks demonstrably contain them, and blockwatcher_evm_bloom_skips_total climbing, with blockwatcher_evm_bloom_contradictions_total still at zerobloom_screen (default true) trusts every fetched header’s logsBloom to prove absence before skipping eth_getLogs (crates/blockwatcher-evm/src/source/rpc/bloom.rs, scan.rs). A running source instance disables the screen for itself the first time a fetched window’s own log contradicts that block’s own bloom, so a skip that is still costing matches with the contradiction counter flat means either this endpoint’s blooms are wrong in a way that never contradicts a log the same endpoint has actually returned (consistently absent, consistently zeroed, or wrong on exactly the blocks this filter never happens to match), or the instance that would have caught it has since restarted and is screening again from a clean slate; see the row below either way.Set bloom_screen = false for that network (crates/blockwatcher-evm/src/source/rpc/config.rs) and confirm the matches appear.
blockwatcher_evm_bloom_contradictions_total is nonzero for a networkAn endpoint returned a log whose own address or topic0 was not admitted by that log’s own block’s bloom, proof that endpoint’s blooms do not describe its own logs (crates/blockwatcher-evm/src/source/rpc/bloom.rs’s admits_log, called from scan.rs). The source instance that observed it has already disabled bloom_screen for itself, logged once as evm-rpc: endpoint's logs bloom does not describe its own returned logs; disabling bloom screening against this endpoint for the lifetime of this source instance, which a restart does not preserve; set bloom_screen = false for this network to keep screening off across a restart too. That disabling belongs to the running instance, not the endpoint or the process: a pipeline restart (the supervisor recovering an exited source, a proven-reorg invalidation, or a monitor change that escalates to a restart) rebuilds the source and resumes screening against the same endpoint. The contradiction also only proves the blooms are wrong, not since when; every window this source screened earlier in the same run rested on the same untrustworthy blooms, and the checkpoint has already advanced past them.Set bloom_screen = false for that network (crates/blockwatcher-evm/src/source/rpc/config.rs) so the setting, not the latch, is what survives a restart. Then read blockwatcher_evm_bloom_skips_total for the same pipeline to see how many windows were screened before the trip, and re-scan that range (POST /monitors/{id}/test’s fetch mode never screens) to recover whatever those windows may have missed; nothing rewinds it automatically.
The same match (same content, same or, for evm-mempool, a different id) is delivered again after a restartExpected: this is at-least-once delivery, not a bug. The checkpoint had not yet advanced past that event when the process stopped, so the restart re-decodes and re-dispatches it.Deduplicate on the match’s id (deterministic given the same network, monitor, and decoded content, via MatchId::derive: crates/blockwatcher-types/src/id.rs); for evm-mempool specifically, deduplicate on the transaction’s own hash field instead, since that source’s id is not stable across a restart.
dead_lettered climbs in GET /status’s counters, and GET /networks/{id}/dead-letters keeps growingThe sink is down, misconfigured, or permanently rejecting deliveries. Every entry’s reason string leads with its ErrorClass rendering (transient, permanent, rate_limited, or retry_narrower) followed by the underlying message, built as format!("{class}: {message}") (crates/blockwatcher-core/src/pipeline/sink_worker.rs), e.g. permanent: webhook host returned 500 five times. A letter whose payload has "type":"retracted" cannot be replayed.Read the reason field to tell “sink refused this outright” from “we ran out of retry budget.” Fix the sink (a bad url_secret, an endpoint that’s actually down) or its config, then POST /networks/{id}/dead-letters/{match_id}/replay for a Match payload: success deletes the letter (204); another exhaustion bumps attempts/reason again and answers 502 replay_failed rather than dropping it. Retract letters are refused with "dead letter payload is a retraction; only match events can be replayed".
Logs show invalidate cursor is older than the retained journal window; some match ids cannot be retracted and blockwatcher_journal_gap_total ticksA deep invalidate rewound past what journal_depth still retained, so some already-delivered match ids have no Retracted event. Rewind and restart still happen (crates/blockwatcher-core/src/engine/invalidate.rs).Raise [engine].journal_depth well above the source’s confirmation window (default 1024). Consumers must handle ids older than the window as an operator problem; the gap is never silent.
A network stops advancing after a source invalidate, in_flight_events stays up, no replacement matches, and blockwatcher_source_invalidation_failures_total ticksAn unrecovered retract: the retract pass failed or dead-lettered without completing, so the checkpoint is left unrewound and the source is not restarted (handle_invalidation, crates/blockwatcher-core/src/engine/invalidate.rs). This is not a crash-restart failure (blockwatcher_pipeline_source_restart_failures_total stays at zero).Inspect GET /networks/{id}/dead-letters for type: retracted payloads, fix the sink, and intervene; the supervisor will not retry this control path on its own the way it retries a crashed source.
An evm-mempool network shows source.status: live, but nothing is ever deliveredThe subscription itself succeeded: Live/Degraded/CatchingUp/Starting (crates/blockwatcher-ports/src/source.rs) only tracks the WebSocket connection’s own health, via dial_failed, stream_closed, transport_error, or idle_timeout (crates/blockwatcher-evm/src/source/mempool/run.rs), not whether the provider actually forwards newPendingTransactions notifications at any useful rate. Some providers, especially free or shared tiers, throttle or silently limit this feed even while answering every other RPC call normally; there is no distinct error for this, because from blockwatcher’s side nothing has failed.Confirm the provider’s plan actually supports mempool streaming (check its docs, or watch whether any hashes arrive by temporarily widening the monitor’s functions list). On a genuinely idle testnet, no one may have called the watched function recently at all; that’s not a provider problem, just a quiet mempool.
blockwatcher check <dir> exits 1 instead of printing ok: N networks, N specs, N sinks, N monitorscheck constructs every module the seed references (crates/blockwatcher/src/check.rs), so it fails on the same things a real boot would: an unregistered module name (unknown {family} module '{name}'; available: […], crates/blockwatcher-core/src/error.rs:9-14), an unresolvable reference, an uncompilable predicate or selector, or a secret an env:NAME reference names that isn’t set in check’s own environment.Read the printed message: it names the offending record or module, never a value that might be a secret. Export every variable a seed’s url_secret references name before running check, exactly as they’d need to be set for a real boot.
Monitor write 422 gate requires 'block.timestamp'Selectors’ schemas have no block.timestamp (mempool-only decode, or a spec that never put the header in the tree)Drop the gate, or select an event/source whose decoder exposes that path. Do not expect the engine to use wall time.
Three Transfers, no digestHits span more than window_ms in block time; or count not yet reached; or untimestamped counter is upInspect timestamps in the decoded event, not wall clock. Check blockwatcher_gate_hits_total vs gate_emits_total.
Second burst never fires after the first digestExpecting a sliding hour that keeps T1; threshold uses session resetAfter fire, T1–T3 are gone; T4–T6 are a new session. That is the specified behaviour.
max_once later hits not in dead lettersDiscard is not throttleExpected. Use throttle if you need replay.
After a 2-block reorg, threshold under-firesJournal was drain-all (bug)Holds with cursor ≤ from must survive. File a bug; replay cannot reconstruct them.
Boot refuses immediately with binding the api listener on 127.0.0.1:8080: Address already in use (os error 48) (or the same for metrics)Something else (often a previous blockwatcher process that didn’t exit cleanly) already holds that port. Binding happens before the engine starts specifically so this surfaces as a boot failure rather than something a running process discovers later (crates/blockwatcher/src/run.rs:34-41,265-269).Find and stop whatever holds the port (lsof -i :8080 or equivalent), or change [api].listen/[metrics].listen (or their BLOCKWATCHER_API__LISTEN/BLOCKWATCHER_METRICS__LISTEN overrides) to a free address.

Reading the exit code

A process that already ran and stopped tells you more than any log line if you check its exit code, from the same table as the CLI’s own --help text (crates/blockwatcher/src/cli.rs):

CodeMeaning
0Clean drain, or a check that passed.
1Config, seed, or boot failure, or a check that refused.
2Shutdown aborted at least one pipeline at the drain deadline.
64The command line itself didn’t parse (unrecognized argument '…', a flag missing its value, etc.).

See Configuration reference § CLI flags for the full command grammar these codes apply to.

When there’s nothing wrong

One thing that looks like a problem and is actually the system working as designed:

  • RUST_LOG unset still prints boot lines, restart notices, and shutdown reports. It defaults to info, not silence, in init_tracing (crates/blockwatcher/src/lib.rs): every diagnostic goes to stderr, never stdout, so it never mixes into match output on a process running with no [api] configured at all.

Benchmarks

scripts/bench/ times blockwatcher end to end against a cached upstream RPC, so a real change in decode, predicate, or delivery cost shows up as a real number rather than noise from repeated network calls. This page states exactly what a number here measures, how to reproduce it, and the baseline every later run diffs against.

The harness itself is chain-agnostic: bench.sh and run-one.sh boot blockwatcher --config <name>/blockwatcher.toml --seed <name>/seed and poll GET /status regardless of which source module the scenario’s network resource names. What a scenario measures, and what its numbers mean, is specific to one chain family, so this page has one section per family. Today that is EVM alone; a second chain family adds its own section here with its own scenarios and its own result columns, rather than being folded into EVM’s table under a different name.

Methodology

Applies to every scenario regardless of chain family.

What every run measures

Each scripts/bench/scenarios/<name>/ is a full blockwatcher instance, booted fresh with storage.module = "memory", so every run starts from the seed alone with nothing carried over from a previous one. A run is timed from process start until GET /status’s checkpoint cursor for the scenario’s one network reaches a fixed end position, matching every occurrence the scenario’s monitor selects along the way and delivering each match to that monitor’s log sink. The clock stops at the checkpoint, not at process exit, but delivery to the sink is inside that window: a monitor whose matches never finish delivering would keep the checkpoint from advancing. The runner polls the status endpoint every 0.2 seconds, so each wall-clock number carries up to about 0.2 seconds of quantization on top of the streamed work it measures.

Out of scope

  • Webhook delivery latency: every scenario’s sink is log, not webhook, so no network hop to a receiving service is timed.
  • Any storage backend other than memory: a scenario’s checkpoint and resources live in process memory for the run’s duration and are discarded when it exits, so SQLite’s or any other backend’s write cost is not represented here.
  • Cold-cache latency against the real upstream: bench.sh warms each scenario’s cache with one discarded run before timing begins, so the timed rows measure blockwatcher’s own cost, not the upstream’s.

Tooling

  • Docker, for the caching proxy each family’s scenarios sit behind.
  • curl, jq, and bc, used by scripts/bench/run-one.sh for status polling, checkpoint extraction, and elapsed-time arithmetic.
  • hyperfine.
  • A release build at target/release/blockwatcher (cargo build --release -p blockwatcher).
  • A real upstream endpoint reachable from this machine for whichever family’s scenarios are being run; see that family’s section for the exact form it takes and how to point the cache at it.

How bench.sh runs a scenario

bench.sh warms each scenario’s cache with one discarded run, times five runs per scenario with hyperfine (exported to scripts/bench/results-<scenario>.md, gitignored as a machine-local artifact), then prints one more counted run per scenario as a scenario,seconds,<count-column> CSV row on its own stdout. The timed rows and the counted row together are what each family’s baseline table transcribes; what the count column measures (RPC attempts, for EVM) is specific to the family.

Rows in a family’s baseline measure the engine as of this page’s own revision: the harness (this page, bench.sh, and each scenario’s seed) ships alongside the code it measures, so the commit and date a row was recorded at are in this file’s git history, and a re-run that changes a number updates the row in the same commit as the change that moved it. Changes that affect decode, predicate, or delivery cost re-run scripts/bench/bench.sh and append their own rows below a family’s baseline rather than replacing it, so a regression or an improvement is visible against a fixed starting point.

EVM

Every scenario in this section runs the evm-rpc source against Ethereum mainnet (chain 1) through a caching eRPC proxy: the numbers describe EVM-family behavior at mainnet density, and carry to neither another chain family nor another EVM source module (evm-mempool is not benchmarked here: it has no historical range to replay against a cache).

Scenarios

Each scenario’s network seed pins the evm-rpc source’s logs_window to { initial: 50, max: 50 }, sized to the baseline upstream’s own eth_getLogs range cap (see Reproducing an EVM run). Without that pin the source’s own default window (initial: 512) is wider than that cap, so its first eth_getLogs call for the range is refused every run and the source narrows and retries; with the pin, no call is ever refused, so what’s measured is blockwatcher’s own decode/predicate/delivery cost against a fixed, upstream-independent request pattern, not the shape of any one provider’s range limit. The request pattern is therefore fixed and upstream-independent (no refusal, no mid-scan narrowing), which is also what makes it fully cacheable: a warm run touches the eRPC cache for every historical call and the real upstream only for head probes.

The rpc-attempts column comes from a separate counted run, not from hyperfine’s five timed runs: hyperfine owns the timed runs’ stdout, so counting is a sixth pass over the same already-warm cache. That count also includes head probes made while the run is in flight, so it varies slightly run to run and between scenarios whose historical request pattern is otherwise identical.

Reproducing an EVM run

BENCH_UPSTREAM_RPC=https://your-mainnet-endpoint.example \
  docker compose -f scripts/bench/compose.yaml up -d
export BLOCKWATCHER_BENCH_RPC=http://127.0.0.1:4000/main/evm/1
scripts/bench/bench.sh

scripts/bench/compose.yaml pins the eRPC image to ghcr.io/erpc/erpc:0.1.2; bumping the pin invalidates recorded rows until the baseline below is re-run.

The baseline below ran with BENCH_UPSTREAM_RPC=https://eth-pokt.nodies.app (a shared, free-tier mainnet endpoint), which is also where the 50-block eth_getLogs cap each seed’s logs_window is pinned to comes from; a different upstream may need a different logs_window value to stay unrefused, per its own advertised range cap. A heavily rate-limited upstream may also need its cache pre-warmed before the first timed run: replaying the range’s own requests (the same methods and params a warm run would make) paced below the provider’s limit fills the eRPC cache without ever tripping it, which a live engine process retrying on its own schedule cannot reliably do against a tight limiter.

Baseline

scenariowall (mean ± σ)rpc attemptsmachine
s1-broad-transfers1.533 s ± 0.127 s153Apple M4 Pro, 64 GB, macOS 26.5.2
s2-selective-weth892.5 ms ± 134.6 ms177Apple M4 Pro, 64 GB, macOS 26.5.2
s3-function-transfer1.565 s ± 0.330 s130Apple M4 Pro, 64 GB, macOS 26.5.2

s1-broad-transfers restricts nothing, so it decodes and delivers every ERC-20 Transfer in the range; its wall time reflects the log sink’s per-match cost multiplied across every one of them, not just RPC or decode cost. s2-selective-weth decodes the same range but keeps almost everything out of its predicate, so its wall time sits far closer to pure RPC-and-decode cost. s3-function-transfer turns on the full-block-with-transactions path (a functions selector fetches every block in a window via eth_getBlockByNumber rather than one ranged eth_getLogs call, plus one eth_getTransactionReceipt per matching call), so its rpc-attempts count reflects that access pattern rather than logs_window’s cap. All three rows use a fully warmed cache, so none of them include upstream latency.

Rows below this baseline carry no date of their own, on the same terms as the baseline itself: the commit and date each was recorded at are in this file’s git history.

Concurrent receipts and batched headers

The evm-rpc source’s receipt_concurrency and header_batch, both at their defaults (4 receipts in flight per leaf, 20 headers per JSON-RPC batch), against the same fixed range, the same seeds, and the same machine as the baseline above.

scenariowall (mean ± σ)rpc attemptsmachine
s1-broad-transfers1.459 s ± 0.181 s12Apple M4 Pro, 64 GB, macOS 26.5.2
s2-selective-weth672.5 ms ± 228.4 ms13Apple M4 Pro, 64 GB, macOS 26.5.2
s3-function-transfer796.3 ms ± 101.9 ms34Apple M4 Pro, 64 GB, macOS 26.5.2

Request count is where the change lands hardest: batching turns a 50-block window’s 50 eth_getBlockByNumber round trips into three, which is what separates 12 and 13 attempts from the baseline’s 153 and 177, and 34 from its 130. Wall time moves only where those round trips dominated it. s3-function-transfer roughly halves, fetching its receipts four at a time across a window whose headers also arrive in one request rather than eight; s2-selective-weth drops about a quarter; s1-broad-transfers stays inside its own spread, since its wall time is the log sink’s per-match cost across every Transfer in the range rather than anything the request count governs.

Logs-bloom pre-screen

The evm-rpc source’s bloom_screen setting, at its default (true), against the same fixed range, the same seeds, and the same machine as the baseline above.

scenariowall (mean ± σ)rpc attemptsmachine
s1-broad-transfers2.101 s ± 0.559 s12Apple M4 Pro, 64 GB, macOS 26.5.2
s2-selective-weth531.7 ms ± 125.0 ms13Apple M4 Pro, 64 GB, macOS 26.5.2
s3-function-transfer1.440 s ± 0.406 s36Apple M4 Pro, 64 GB, macOS 26.5.2

Request counts hold steady against the previous section’s rows: 12 and 13 attempts match exactly, and 34 moving to 36 reflects how far past the end block each run got before the runner stopped it, described below, rather than a change in the historical request pattern. Wall time for each scenario sits inside its own spread from the previous section, so nothing here reads as a regression or an improvement on its own.

The column’s own arithmetic caps how far this setting could ever move it. A screened window skips exactly one call, its eth_getLogs, and it cannot skip any of the header requests that came before: those headers are the screen’s own input, since a window’s blooms are read out of them, so every one is already fetched by the time the decision exists. One window’s own cost is ceil(logs_window / header_batch) header requests plus that single log call, which is three and one at a 50-block window and a 20-header batch. Reading one s2-selective-weth run’s requests out of the eRPC instance’s own log shows exactly that shape, three eth_getBlockByNumber for every eth_getLogs. So a window spends three header requests per log call, and a screen that fired on every window in the range would still leave those three standing. Set against the previous section’s rows, which already cut 153, 177, and 130 attempts down to 12, 13, and 34 by batching those same headers, the log call is the smaller remaining share rather than the dominant one, so a large drop in this column was never available here at any fill rate.

That same log also shows why a row’s total is not the benchmarked range divided by the window width. The run issued four log calls, covering 22000000 to 22000049 and 22000050 to 22000099 inside the range, and 22000100 to 22000149 and 22000150 to 22000199 beyond it. The runner stops a scenario once the pipeline’s checkpoint reaches the end block, and by that point the source has already started the windows after it, so a row’s total carries however many of those a given run reached before it was stopped. That is why these totals move between runs in steps of roughly one window’s cost rather than a request at a time, and why a row is an upper bound on the requests the benchmarked range itself needs rather than an exact count of them.

That also makes this column the wrong instrument for the saving the setting is named for. A skipped eth_getLogs is one fewer log call, and a run that skipped every log call it could would still report most of the attempts it reports now, because the header requests and the head probes are counted here too. blockwatcher_evm_bloom_skips_total counts the windows that skipped, which is the quantity the setting acts on directly.

Holding steady is the expected result on this fixture, not evidence that the screen does nothing. The screen skips a window’s eth_getLogs call only when every fetched header’s bloom refutes every monitored address, or every monitored topic0, in that window, since eth_getLogs requires both to hold and either dimension refuted alone already rules out a match; a refutation itself is certain only when the m3 membership test returns a negative for an item genuinely absent from that block. Sampling every seventh block of the benchmarked range (landing on 22000000 through 22000098) through the same eRPC cache and counting set bits in each logsBloom puts the average fill across this range at about 45% of its 2048 bits, so even for an item absent from a given block, bit collisions alone let the test falsely report it present in roughly 0.45^3 (about 9%) of blocks. A 50-block window only skips when all 50 of its headers refute, so the probability that every one of them does compounds to under 1%, even for an address that appears nowhere on chain; at that rate a skip is a rare event across a handful of windows, not a routine one. On top of that baseline rarity, s2-selective-weth monitors an address and topic0 that genuinely occur inside the range, so its windows could never skip regardless of fill rate, and s1-broad-transfers restricts nothing at all, so a filter with no address or topic to refute against is never screened by design.

Where the setting earns its keep is sparser blocks and narrower windows: a chain or range whose headers carry a lower average bloom fill pushes the false-admission rate down for every block in a window, and a smaller logs_window needs fewer of those blocks to agree before it can skip. Re-running the s2-selective-weth scenario against this same cached range, with its seed’s logs_window pinned to { initial: 10, max: 10 } and its monitored address replaced by one that appears nowhere in the range, then reading blockwatcher_evm_bloom_skips_total off the metrics endpoint confirms the mechanism directly: those narrower windows do skip, and the series appears and grows as the run proceeds. The rows above are the honest result for a dense mainnet range at the seeds’ default window, not the feature’s best case.

Internals

This part is for a contributor changing blockwatcher’s own code: how the workspace’s crates are grouped and why, what each crate owns, how the test suite is layered, and the binding decisions those boundaries answer to. It assumes the concepts pages already, and reads the source alongside it.

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
click sources "../concepts/selectors.html"
click decoder "../concepts/chain-agnosticism.html"
click matcher "../concepts/predicates.html"
click gate "../concepts/gates.html"
click sinks "../concepts/delivery.html"
click storage "../concepts/resources.html"
click api "../reference/http-api.html"
click metrics "../reference/observability.html"
click engine "../concepts/pipeline.html"

Key takeaways

  • This part is for a contributor changing blockwatcher’s own code, and assumes the concepts pages already.

  • The workspace map explains how crates are grouped by role and how they depend on each other.

  • Testing strategy and architecture decisions describe how the test suite is layered and which decisions are binding.

  • Extending blockwatcher walks a new module through the whole process end to end.

  • Workspace map: the crates, grouped by role, with their dependency edges.

  • Testing strategy: the layers of tests and what each one proves.

  • Extending blockwatcher: the mechanical steps a new module goes through, worked through a real one end to end.

  • Architecture decisions: the binding decisions restated from the contributor rulebook.

  • The trust boundary (API scopes, companion login, ingest header) lives in the repository’s docs/threat-model.md, next to SECURITY.md.

Workspace map

blockwatcher’s Cargo.toml declares one workspace of member crates. This page groups them by the role each one plays, shows the actual dependency edges between them, and explains how the grouping was checked against scripts/check-dep-graph.sh (the script CI runs to fail a build the moment a crate’s dependencies drift outside its assigned role), rather than simply asserted from memory.

Key takeaways

  • blockwatcher’s workspace crates are grouped into three rings, each crate sitting in exactly one: core (cannot do without, forbidden from chain/HTTP/storage knowledge), modules (chain knowledge or external systems), and glue and test (the composition root plus test-only scaffolding).
  • The grouping is verified, not asserted: it falls out of what check-dep-graph.sh actually enforces per crate, an allowlist of direct dependencies plus a denylist checked against the full transitive tree.
  • The dependency graph table lists every production workspace-internal edge; the diagram below it is the transitive reduction, so no edge is silently dropped, only pruned from the picture.

Three rings

Core

The crates a running instance cannot do without, and the only crates the dependency gate forbids from ever seeing a chain SDK, an HTTP client, or a storage driver.

  • blockwatcher-types, the vocabulary crate: the canonical value model, resource shapes, and deterministic IDs. It depends on nothing but serde and two arbitrary-precision integer crates.
  • blockwatcher-ports, the trait boundary: the six port definitions, their error enums, and the in-memory fakes that stand in as proof that a port hides no chain-specific detail.
  • blockwatcher-core, the engine: pipeline assembly, checkpointing, hot reload of resources, and the control handle the API drives.
  • blockwatcher-api, the REST surface: turns HTTP requests into calls against the engine’s control handle.

Modules

The crates that hold chain knowledge or talk to an external system, each built so that adding, removing, or swapping one never touches a core file.

  • blockwatcher-expr, the default predicate language: a small expression engine that is also the shipped matcher module.
  • blockwatcher-rpc, a chain-agnostic endpoint pool: retries, failover, and health tracking over any request/response transport, without ever deciding what a request means.
  • blockwatcher-storage, the storage backends: an in-memory implementation and a sqlite-backed one, both behind the Storage port.
  • blockwatcher-sinks, the delivery modules: webhook, script, and log sinks.
  • blockwatcher-gates, the gate modules: threshold and max_once. Depends on types + ports, never core; catalog fold is in blockwatcher-embed.
  • blockwatcher-metrics, Prometheus wiring: installs a recorder and serves the metrics scrape endpoint from its own axum server.
  • blockwatcher-evm, the EVM chain family: the evm-rpc and evm-mempool sources and the EVM log decoder, built on blockwatcher-rpc and the alloy SDK.

Glue and test

The composition root and the crates that exist only to make the rest of the workspace testable. blockwatcher-embed is loaded in production when a host (or the binary) boots the engine; the test crates are not.

  • blockwatcher-embed, the in-process composition façade: build_catalog and the engine types a host needs to boot without the binary’s CLI, API, metrics, or signals. Glue, like the binary: it may depend on blockwatcher-core.
  • blockwatcher (binary), the composition root: wires modules into a running process, seeds initial resources on first boot, and offers an offline config check. Catalog construction is delegated to blockwatcher-embed.
  • blockwatcher-testkit, shared test scaffolding: a recording metrics recorder and harnesses that exercise a port’s contract the same way across every module that implements it.
  • blockwatcher-evm-testkit, a scripted mock JSON-RPC and WebSocket node that blockwatcher-evm’s own tests drive; nothing production-facing ever depends on it.
  • blockwatcher-e2e, black-box tests that drive the real blockwatcher binary against a local chain; it ships no production code of its own at all.

Verifying the rings

The three rings above are not a description layered on top of the crates. They fall out of what scripts/check-dep-graph.sh actually enforces per crate. The script runs two checks against every workspace crate: an allowlist of the crates and external packages each one may depend on directly, and a denylist of chain-SDK/HTTP/storage/web-framework package families that must not appear anywhere in that crate’s transitive tree, with narrow, per-crate exemptions where a crate’s own direct allowlist already approved exactly one such family.

Reading those two lists crate by crate gives a mechanical way to reproduce each ring, rather than trusting a label:

  • Core is blockwatcher-types, blockwatcher-ports, and blockwatcher-core, plus every crate whose allowlist is permitted to name blockwatcher-core as a direct dependency and which is not glue. Only blockwatcher-api qualifies for that core-ring “plus”: no module crate’s allowlist names blockwatcher-core. blockwatcher-embed and the blockwatcher binary also name blockwatcher-core, and they are classified as glue rather than core because they are composition roots, not an HTTP surface over the engine. blockwatcher-types, blockwatcher-ports, and blockwatcher-core carry no family exemption at all in the script (they cannot carry a chain SDK, an HTTP client, or a storage driver anywhere in their tree); blockwatcher-api carries exactly one exemption, for the web-framework family its own allowlist already approves (axum and what it pulls in), and no others, so it can serve HTTP without ever being able to see a chain SDK or a storage driver either.
  • Glue and test is the binary, blockwatcher-embed, plus the crates the script names explicitly rather than folding into the general allowlist rule: blockwatcher-testkit and blockwatcher-evm-testkit are checked by name to confirm no crate outside [dev-dependencies] depends on either (the mechanical form of “test scaffolding never ships”), and blockwatcher-e2e’s allowlist entry is empty on purpose, so any production dependency at all fails the check; the crate is structurally incapable of shipping code. The binary’s own allowlist is exempted for every chain-SDK/HTTP/storage family the module crates individually carry, because it is the one crate that links all of them into a single process, plus axum for the listeners it owns. blockwatcher-embed‘s allowlist names blockwatcher-core and the module crates it folds; its family exemption covers those modules’ families (alloy, reqwest, hyper, tower-http, rusqlite) and not axum, because embed never serves HTTP.
  • Modules is everything left over: blockwatcher-expr, blockwatcher-rpc, blockwatcher-storage, blockwatcher-sinks, blockwatcher-gates, blockwatcher-metrics, and blockwatcher-evm. Each either carries its own single-family exemption for the reason it exists (blockwatcher-storage for rusqlite, blockwatcher-sinks for reqwest, blockwatcher-evm for alloy and its transport crates, blockwatcher-metrics for axum) or, for blockwatcher-expr, blockwatcher-rpc, and blockwatcher-gates, carries none because none needs one, and none of them appears in the core or glue/test criteria above.

This reproduces the brief’s grouping exactly, derived from the gate’s own rules rather than restated from a design doc.

Dependency graph

The table below lists every workspace-internal edge exactly as declared in each crate’s [dependencies] section (production dependencies only: [dev-dependencies] edges, such as every crate’s dev-dependency on blockwatcher-testkit, are deliberately excluded, since those are what the gate above forbids from ever becoming production edges).

CrateDepends on (workspace, [dependencies] only)
blockwatcher-typesnone
blockwatcher-portsblockwatcher-types
blockwatcher-coreblockwatcher-types, blockwatcher-ports
blockwatcher-apiblockwatcher-types, blockwatcher-ports, blockwatcher-core
blockwatcher-exprblockwatcher-types, blockwatcher-ports
blockwatcher-rpcblockwatcher-ports
blockwatcher-evmblockwatcher-types, blockwatcher-ports, blockwatcher-rpc
blockwatcher-evm-testkitblockwatcher-types, blockwatcher-evm, blockwatcher-rpc
blockwatcher-testkitblockwatcher-types, blockwatcher-ports
blockwatcher-storageblockwatcher-types, blockwatcher-ports
blockwatcher-sinksblockwatcher-types, blockwatcher-ports
blockwatcher-gatesblockwatcher-types, blockwatcher-ports
blockwatcher-metricsnone
blockwatcher-embedblockwatcher-core, blockwatcher-storage, blockwatcher-gates, blockwatcher-expr, blockwatcher-evm, blockwatcher-sinks
blockwatcher (binary)blockwatcher-types, blockwatcher-ports, blockwatcher-core, blockwatcher-embed, blockwatcher-api, blockwatcher-metrics, blockwatcher-storage
blockwatcher-e2enone

That is 36 edges across 16 nodes, most of them implied by another edge in the same table (blockwatcher-core depends on blockwatcher-types directly, but also reaches it via blockwatcher-ports, which already depends on blockwatcher-types). Drawing all 36 makes the diagram unreadable without adding information, so the diagram below draws the transitive reduction instead: it keeps an edge only when no other path in the table above already reaches the same target, and every edge it omits is one step away from an edge it keeps: for example blockwatcher-core → blockwatcher-types is omitted because blockwatcher-core → blockwatcher-ports → blockwatcher-types already covers it, and the same reduction removes blockwatcher-api’s direct edges to blockwatcher-types and blockwatcher-ports (both reachable via blockwatcher-api → blockwatcher-core), blockwatcher-evm’s direct edges to blockwatcher-types and blockwatcher-ports (reachable via blockwatcher-evm → blockwatcher-rpc), blockwatcher-embed’s direct edge to blockwatcher-core’s own dependencies, and four of the binary’s seven direct edges (blockwatcher-types, blockwatcher-ports, and blockwatcher-core are reachable via blockwatcher → blockwatcher-api; blockwatcher-storage is reachable via blockwatcher → blockwatcher-embed). No edge is added that is not in the table above; none of the 16 nodes or 36 edges is silently dropped from the record. They are only pruned from the picture.

graph TD
  subgraph core["core"]
    types[blockwatcher-types]
    ports[blockwatcher-ports]
    core_[blockwatcher-core]
    api[blockwatcher-api]
  end

  subgraph modules["modules"]
    expr[blockwatcher-expr]
    rpc[blockwatcher-rpc]
    storage[blockwatcher-storage]
    sinks[blockwatcher-sinks]
    gates[blockwatcher-gates]
    metrics[blockwatcher-metrics]
    evm[blockwatcher-evm]
  end

  subgraph glue["glue / test"]
    embed[blockwatcher-embed]
    bin[blockwatcher]
    testkit[blockwatcher-testkit]
    evmtestkit[blockwatcher-evm-testkit]
    e2e[blockwatcher-e2e]
  end

  ports --> types
  core_ --> ports
  api --> core_

  expr --> ports
  rpc --> ports
  storage --> ports
  sinks --> ports
  gates --> ports
  evm --> rpc

  evmtestkit --> evm
  testkit --> ports

  embed --> core_
  embed --> storage
  embed --> expr
  embed --> evm
  embed --> sinks
  embed --> gates

  bin --> api
  bin --> metrics
  bin --> embed

blockwatcher-metrics and blockwatcher-e2e are the only nodes with no outgoing edge at all, in either the full table or the reduced diagram, and that absence is itself a checked property rather than an omission: blockwatcher-metrics declares no workspace crate in [dependencies] (it wraps metrics-exporter-prometheus directly), and blockwatcher-e2e declares no [dependencies] at all: the empty allowlist entry scripts/check-dep-graph.sh gives it exists precisely to keep that crate at zero production dependencies. blockwatcher-testkit and blockwatcher-evm-testkit have outgoing edges but no incoming ones in this graph, because every crate that uses them does so through [dev-dependencies], which this graph excludes by construction (the same rule the dependency gate checks by name for those two crates).

blockwatcher-types

blockwatcher-types is blockwatcher’s vocabulary crate: the canonical value model every decoder normalizes into, the shapes of every configuration resource (Network, Spec, Monitor, SinkDef, …), the schema types a predicate type-checks against, and the deterministic identifiers that let a downstream consumer deduplicate under at-least-once delivery. Nothing here runs anything: there is no pipeline, no async runtime, no retry loop. This crate only defines what everything else in the workspace passes around.

Its dependency list is the point, not an implementation detail: serde, serde_json, num-bigint, and indexmap, and nothing else. Every other crate in the core ring (blockwatcher-ports, blockwatcher-core, blockwatcher-api) and every module crate builds on this vocabulary without pulling in a chain SDK, an HTTP client, or a storage driver by way of it, because there is nothing in this crate’s own tree for them to pull in.

Key takeaways

  • blockwatcher-types is blockwatcher’s vocabulary crate: the canonical value model, every resource shape, the schema types a predicate checks against, and the deterministic identifiers a consumer deduplicates by.
  • Nothing here runs anything: no pipeline, no async runtime, no retry loop; this crate only defines what everything else passes around.
  • It depends on serde, serde_json, num-bigint, and indexmap, and nothing else, which is what lets every other crate build on this vocabulary without pulling in a chain SDK, an HTTP client, or a storage driver by way of it.

Responsibilities

  • Define Value, the canonical value model every decoded field is expressed in: see Chain-agnosticism for the full discussion; this page does not repeat it.
  • Define the configuration resource shapes: Network, Spec, Monitor, SinkDef, ModuleSel, RawSelector, DeliveryRetry, ResourceKind, VersionedRecord (the JSON-serializable structs a storage backend persists and the HTTP API accepts and returns). Monitor.gate is Option<ModuleSel>; config structs live with the modules (threshold / max_once in blockwatcher-gates), not a closed MonitorGate enum in types. GateHitRecord and GateMeta are the persisted journal shapes (gate.rs).
  • Define the schema vocabulary a predicate type-checks against: ValueType, EventSchema, FieldSchema, SchemaSet, and the FieldResolution/ FieldOrigin pair SchemaSet::resolve returns.
  • Define the pipeline’s own data shapes: RawEvent/RawPayload (what a source emits), DecodedEvent (what a decoder produces), Match (what a matcher accepts), SinkEvent (what a sink receives: Match or Retracted), and DeadLetter (what a sink gives up on).
  • Define Cursor and Checkpoint, the position and resume-point types every source’s progress is tracked in, without interpreting either one.
  • Define the deterministic identifier types: the string_id!-built newtypes (NetworkId, MonitorId, SinkId, SpecId, ChainKind) and MatchId, whose derive function is the one hashing algorithm in the crate.
  • Define SecretRef, the env:NAME reference type, and note that resolve is the one place this crate touches the outside world at all.

Not this crate’s job: deciding what a chain-specific decoder does with any of these shapes (blockwatcher-evm and future chain modules own that); defining the port traits that operate on them (blockwatcher-ports); running a pipeline, retrying a delivery, or persisting a resource (blockwatcher-core, blockwatcher-storage); the predicate language itself: this crate defines the schema vocabulary a predicate checks against, not the grammar or evaluator (blockwatcher-expr, see Predicates and the expression language).

Key types and traits

NameKindRole
ValueenumThe canonical value model; see Chain-agnosticism
Network, Spec, Monitor, SinkDef, ModuleSelstructConfiguration resource shapes stored via the Storage port and exposed over the HTTP API
RawSelector, DeliveryRetry, ResourceKind, VersionedRecordstruct/enumSupporting resource vocabulary: a monitor’s selector entry, the engine’s delivery-retry policy, the four resource kinds, and storage’s optimistic-concurrency envelope
ValueTypeenumThe type each schema field is declared at, shaped after Value’s own variants
EventKindstructAn open, string_id!-built vocabulary of occurrence kinds (event/function_call/transaction, or a chain family’s own); carried by both EventSchema and DecodedEvent, and hashed directly in MatchId::derive
EventSchema, FieldSchemastructOne declared occurrence and one declared field within it
SchemaSetstructThe fields and namespaces a compiled monitor makes available for a predicate to check against; resolve is the field-path resolution algorithm every predicate compile runs against
FieldResolution, FieldOriginenumThe three-way outcome of SchemaSet::resolve and whether a resolved field was declared or derived through an array
Cursor, CheckpointstructA source’s position in its own feed, and the resume point (cursor plus opaque source_state) a network’s pipeline persists
RawEvent, RawPayloadstruct/enumWhat a source emits into the pipeline, before decoding
DecodedEventstructThe normalized output of decoding: kind, name, canonical Value fields, cursor
Match, SinkEvent, DeadLetterstruct/enumOne accepted occurrence (with its deterministic MatchId), the closed event a sink delivers (Match or Retracted { match_id }), and the record of one that exhausted its delivery budget
MatchId, NetworkId, MonitorId, SinkId, SpecId, ChainKindstructDeterministic and open-vocabulary identifier newtypes; MatchId::derive is the crate’s one hashing algorithm
SecretRefstructAn env:NAME reference; resolve is the crate’s one I/O operation

How data flows through it

This crate has no pipeline of its own: its “flow” is the sequence of types that hand off from one pipeline stage to the next, all defined here even though the stages themselves live in other crates:

flowchart LR
    Config["Network / Spec / Monitor / SinkDef<br/>(write time)"] -.->|"read by"| Decoder
    Config -.->|"read by"| Matcher
    Source -->|"RawEvent / RawPayload"| Decoder
    Decoder -->|"DecodedEvent<br/>(Value tree, via SchemaSet)"| Matcher
    Matcher -->|"Match<br/>(MatchId::derive)"| SinkEvent
    SinkEvent -->|"Sink::deliver"| Sink
    Sink -.->|"on exhausted retries"| DeadLetter

Every arrow above is a type this crate defines; every box is a port implementation that lives elsewhere. SchemaSet::resolve runs once, at write time, when a monitor’s predicate is compiled against its selectors’ schemas, and never again while that pipeline is running: a decoded event at evaluation time is read through the already-resolved Ast, not re-resolved against the schema.

Neighbours

blockwatcher-types depends on the following in production (nothing else in the workspace):

  • serde: (de)serialization derive support
  • serde_json: JSON wire format
  • num-bigint: arbitrary-precision integers
  • indexmap: ordered maps

In [dev-dependencies] only:

  • proptest: property tests in id.rs over MatchId::derive

The following crates depend on it directly (per the dependency table):

  • blockwatcher-ports: trait boundary for all modules
  • blockwatcher-core: engine pipeline
  • blockwatcher-api: HTTP routes
  • blockwatcher-expr: predicate language and matcher module
  • blockwatcher-evm: EVM chain module
  • blockwatcher-evm-testkit: EVM module tests
  • blockwatcher-testkit: workspace test fixtures
  • blockwatcher-storage: storage module
  • blockwatcher-sinks: sink modules
  • blockwatcher (binary): process binary

blockwatcher-rpc reaches it only transitively, through blockwatcher-ports.

Reading the source

  1. Start at lib.rs: the module list, the re-exports, and the doc comment stating the crate’s one I/O exception (SecretRef::resolve).
  2. value.rs: the Value enum, its decimal/hex wire encoding, and from_json/get_path, the two general-purpose helpers built on it.
  3. schema.rs: ValueType, EventKind (an open vocabulary, not limited to the three constants it ships), EventSchema, SchemaSet, and resolve, the field-path resolution algorithm every predicate compile depends on.
  4. resource.rs: the configuration resource shapes: Network, Spec, Monitor, SinkDef, DeliveryRetry, RawSelector, ResourceKind, VersionedRecord. Monitor.gate is Option<ModuleSel>.
  5. gate.rs: GateHitRecord, GateMeta, GATE_HITS_CAP.
  6. id.rs: the string_id! macro, the identifier newtypes it builds, and MatchId::derive’s hashing algorithm.
  7. event.rs: the pipeline’s own data shapes: RawEvent, RawPayload, DecodedEvent, Match, SinkEvent, DeadLetter.
  8. cursor.rs: Cursor and Checkpoint.
  9. hex.rs: the internal 0x-hex serde helpers Value and RawPayload both serialize through.
  10. secret.rs: SecretRef, and the crate’s one I/O exception.

blockwatcher-ports

blockwatcher-ports is the trait boundary that makes blockwatcher chain-agnostic: it defines the port traits every module implements (Source, Decoder, Matcher, Gate, Sink, Storage), their write-time and runtime error types, the registration contract every module family enumerates itself through, and, behind two features, the in-memory test doubles that let consumers exercise a port’s contract without linking any real module at all.

Its production dependencies are blockwatcher-types plus async plumbing (tokio, tokio-util, futures, async-trait) and (de)serialization glue (serde, serde_json, thiserror). No chain SDK, no HTTP client, no storage driver appears anywhere in its allowlist or its transitive tree; that is what lets blockwatcher-core, which depends on this crate directly, drive a pipeline without ever importing one.

Key takeaways

  • blockwatcher-ports is the trait boundary that makes blockwatcher chain-agnostic: it defines the port traits (Source, Decoder, Matcher, Gate, Sink, Storage), their error types, and the module registration contract.
  • Behind the fakes and testing features it also provides in-memory test doubles for every port, so a consumer can exercise a port’s contract without linking any real module.
  • No chain SDK, HTTP client, or storage driver appears anywhere in its allowlist or transitive tree, which is what lets blockwatcher-core depend on it directly without importing one.

Responsibilities

  • Define the port traits and the associated types each one needs: Source (SourceCtx, SourceStatus, SourceOutcome, InterestSet, ScanRange), Decoder (CompiledSpec, CompiledSelector, DecodeOutcome, SpecSet), Matcher (CompiledPredicate, Explanation, ExplanationNode), Gate (CompiledGate, GateHit, GateDecision, GateAux, GateCtx), Sink, and Storage (BatchEntry).
  • Define the write-time and runtime error enum for each port (SourceError, SpecError, SelectorError, PredicateError, SinkError, GateError, StorageError) and ErrorClass, the retry classification every one of them reports through the Classify trait.
  • Define ModuleRegistry, the trait a module family implements once per module to name itself and hand back a constructor, plus the *Factory type alias beside each port trait that its Factory associated type resolves to.
  • (fakes feature) Provide one in-memory implementation per port: FakeSource, FakeDecoder, FakeMatcher, PassthroughGate, FakeSink, MemoryStorage, and FlakyStorage (a fault-injecting wrapper around MemoryStorage), each registered through the same ModuleRegistry contract a real module uses, so they are executable proof that a port hides no backend-specific detail, not just a convenience.
  • (testing feature) Re-export mockall-generated mocks: one per port trait (MockSource, MockDecoder, MockMatcher, MockGate, MockSink), and one per storage facet (MockResourceStore, MockCheckpointStore, MockDeadLetterStore, MockPauseStore, MockDeliveryJournal, MockGateStore) for unit-level substitution.

Not this crate’s job: implementing any real module: blockwatcher-evm, blockwatcher-rpc, blockwatcher-storage, blockwatcher-sinks, and blockwatcher-expr each implement exactly one port over real chain, network, or storage logic; blockwatcher-gates implements Gate the same way; running a pipeline, retrying a failed delivery, or deciding when a checkpoint advances (blockwatcher-core owns all of that: a Sink or Source implementation never retries internally); the predicate language’s grammar and evaluator (blockwatcher-expr defines those against the Matcher trait this crate only declares).

Key types and traits

NameKindRole
SourcetraitPulls raw activity into a pipeline; owns its own cursor semantics and run loop
DecodertraitCompiles a chain artifact into chain-agnostic schemas once, then decodes raw payloads against the compiled result
MatchertraitCompiles predicate source against a SchemaSet, then evaluates it against decoded events
GatetraitCompiles a monitor’s gate.config, then decides Retain/Discard/Emit over an engine-owned journal
SinktraitDelivers one SinkEvent (Match or Retracted); the engine owns all retry/backoff/dead-letter policy
StoragetraitResource CRUD, checkpoints, dead letters, and the bounded delivery journal, with optimistic concurrency on every write
ModuleRegistrytraitThe NAME + Factory + factory() contract every module declares itself through
ClassifytraitReports one of four ErrorClass values for any port error
ErrorClassenumTransient / Permanent / RateLimited / RetryNarrower: the whole retry-policy vocabulary
SourceError, SpecError, SelectorError, PredicateError, SinkError, GateError, StorageErrorenumOne thiserror enum per port, each with its own Classify impl
CompiledSpec, CompiledSelector, CompiledPredicate, CompiledGatestructType-erased compiled artifacts (downcast::<T>()), built once at write time and read on the hot path
InterestSet, ScanRangestructHints a source may use to narrow fetching, and the bounded range for a one-shot history scan
SourceCtx, SourceStatus, SourceOutcomestruct/enumEverything the engine hands a running source, its typed liveness status, and how run returns without a SourceError (Ended or Invalidated { from })
DecodeOutcomestructOne decode() call’s result: decoded events plus an undecodable payload count
Explanation, ExplanationNodeenum/structThe dry-run “why did or didn’t this match” tree a Matcher::explain returns
BatchEntrystructOne create in Storage::put_batch’s create-only batch
FakeSource, FakeDecoder, FakeMatcher, PassthroughGate, FakeSink, MemoryStorage, FlakyStoragestruct(fakes feature) In-memory, minimal implementations of every port

Object safety

Every port trait is consumed as a trait object, never through its concrete type: each *Factory alias resolves to Arc<dyn Source>, Arc<dyn Decoder>, Arc<dyn Matcher>, Arc<dyn Gate>, Arc<dyn Sink>, or Arc<dyn Storage> (source.rs, decoder.rs, matcher.rs, gate.rs, sink.rs, storage.rs), and every module’s registered factory constructs and returns exactly one of those. Staying dyn-compatible is load-bearing for that reason, and every trait keeps it this way:

  • No generic methods. None of them declares a method with its own type parameter: every method’s arguments and return type are concrete port types (&RawEvent, &CompiledSpec, &SchemaSet, and so on). The generic constructors this crate does define, CompiledSpec::new::<T> and CompiledSelector::new::<T> (decoder.rs) and CompiledPredicate::new::<T> (matcher.rs) and CompiledGate::new::<T> (gate.rs), are inherent methods on plain structs, not methods on a port trait, so they never have to satisfy object safety at all.
  • async fn only behind the macro that makes it dyn-compatible. Source, Sink, and Storage each declare async fn methods and are annotated #[async_trait] (source.rs, sink.rs, storage.rs), which rewrites every async fn into a plain fn returning a boxed, pinned future (the shape a trait object can actually hold), since a bare async fn in a trait is not itself object-safe. Decoder and Matcher have no async methods at all: compile_spec, compile, decode, interest, and merge_interest on Decoder, and compile, matches, explain, and referenced_fields on Matcher, all run synchronously, so both stay a plain pub trait X: Send + Sync with no macro needed (decoder.rs, matcher.rs). Gate is the same shape: compile, on_hit, and defaulted on_invalidate are synchronous (gate.rs).

The Send + Sync bound itself sits once, on the trait declaration, for the same reason: every one is declared pub trait X: Send + Sync as a supertrait bound (source.rs, decoder.rs, matcher.rs, gate.rs, sink.rs, storage.rs), so every dyn X is already Send + Sync by construction. None of the *Factory aliases repeats the bound on its own Arc<dyn X>: there is no Arc<dyn X + Send + Sync> anywhere in this crate, because the supertrait already settled it once, at the trait itself.

How data flows through it

The crate’s defining shape is the split between write time, when a monitor or spec is compiled into an opaque artifact, and the hot path, which only ever reads that artifact back through a typed downcast:

flowchart TD
    subgraph wt["Write time: once per monitor/spec"]
        Spec -->|"Decoder::compile_spec"| CS["CompiledSpec"]
        CS -->|"Decoder::compile(selectors, specs)"| CSel["CompiledSelector"]
        Text["predicate source"] -->|"Matcher::compile(text, schemas)"| CP["CompiledPredicate"]
    end
    subgraph hp["Hot path: once per event"]
        Raw["RawEvent"] -->|"Decoder::decode(raw, selector)"| DE["DecodedEvent"]
        DE -->|"Matcher::matches(predicate, event)"| Bool["bool"]
        Bool -->|"Sink::deliver(event)"| Result["Ok / SinkError"]
    end
    CSel -.->|"read via downcast"| DE
    CP -.->|"read via downcast"| Bool

Every port besides Storage follows this same compile-once, evaluate-many shape; Storage is the exception, since every one of its operations is already a runtime call with no separate write-time compilation step.

Neighbours

blockwatcher-ports depends on the following in production:

  • blockwatcher-types: vocabulary crate
  • tokio: async plumbing
  • tokio-util: async utilities
  • futures: future combinators
  • async-trait: trait async support
  • serde: (de)serialization derive
  • serde_json: JSON values for module configuration and factories
  • thiserror: error derive

Optionally in production (behind testing and fakes features):

  • mockall: mockall-generated trait mocks (testing feature)
  • num-bigint: arbitrary-precision integers (fakes feature)

In [dev-dependencies] only:

  • tokio (test-util feature): for fakes module async tests

The following crates depend on it directly (per the dependency table):

  • blockwatcher-core: engine pipeline
  • blockwatcher-api: HTTP API
  • blockwatcher-expr: predicate language and matcher module
  • blockwatcher-rpc: RPC chain module
  • blockwatcher-evm: EVM chain module
  • blockwatcher-testkit: workspace test fixtures
  • blockwatcher-storage: storage module
  • blockwatcher-sinks: sink modules
  • blockwatcher-gates: gate modules
  • blockwatcher (binary): process binary

blockwatcher-evm-testkit reaches it only transitively, through blockwatcher-evm and blockwatcher-rpc.

Reading the source

  1. Start at lib.rs: the module list, the full set of re-exports, and which of them are feature-gated.
  2. error.rs: ErrorClass, Classify, and the seven port error enums; read this before any port trait, since every fallible method returns one of these.
  3. registry.rs: ModuleRegistry and BoxFuture, the shared contract every module family’s own registration builds on.
  4. source.rs, decoder.rs, matcher.rs, gate.rs, sink.rs, storage.rs: the port traits, each beside the associated types and factory alias it needs; read in this order, since Source’s InterestSet and Decoder’s interest/merge_interest defaults are easiest to follow before Matcher, Gate, and Sink, which are simpler traits.
  5. fakes/mod.rs, then fakes/source.rs, fakes/decoder.rs, fakes/matcher.rs, fakes/gate.rs, fakes/sink.rs, fakes/storage.rs, and fakes/flaky_storage.rs: one minimal implementation per port, each registered through ModuleRegistry exactly as a real module would be.

blockwatcher-expr

blockwatcher-expr is the internals of blockwatcher’s default predicate language and the expr matcher module that wraps it: a lexer, a recursive-descent parser, a write-time type-checker, and a total three-valued evaluator, plus the ExprMatcher port implementation and module registration that make the whole pipeline selectable as matcher = "expr" in blockwatcher.toml. It is a module crate: chain-agnostic itself, and the one Matcher in the shipped module catalog (see Modules), though the port boundary in blockwatcher-ports never limits a deployment to it.

This page covers the crate’s own internals: parsing, type-checking, compilation, and evaluation. It does not restate the predicate language’s grammar, operator table, or type system, which Predicates and the expression language already documents in full from an operator’s point of view; where the two overlap, this page links there rather than duplicating it.

Key takeaways

  • blockwatcher-expr is the predicate language’s internals: a lexer, a recursive-descent parser, a write-time type-checker, and a total three-valued evaluator, plus the ExprMatcher port implementation.
  • It is chain-agnostic itself and the one Matcher in the shipped module catalog, though the port boundary never limits a deployment to it.
  • This page covers the crate’s own parsing, type-checking, compilation, and evaluation internals; the language’s grammar and type system are already documented from an operator’s point of view on Predicates.

Responsibilities

  • Scan predicate source text into an ordered stream of tokens, each one tagged with the byte range it came from, decoding numeric, hex, and string literals as it goes so nothing downstream re-parses them (lexer.rs).
  • Parse tokens into a spanned Ast via recursive descent, one function per precedence level, enforcing three bounds (nesting depth, tree height, and source length) so a hostile predicate cannot exhaust the stack or the heap at compile time (parser.rs).
  • Type-check the Ast against a monitor’s SchemaSet, admitting or rejecting every operator’s operands and producing an operator-facing PredicateError (with an edit-distance did-you-mean suggestion for an unknown field or namespace) the moment a predicate is written (typecheck/mod.rs, typecheck/diagnostics.rs).
  • Evaluate a type-checked Ast against a DecodedEvent with three-valued (Kleene) logic: total, allocation-light, and never a panic (eval.rs).
  • Wrap the whole pipeline as Predicate (compile/matches/explain/ referenced_fields) and as ExprMatcher, the blockwatcher-ports::Matcher implementation the expr module registers under (lib.rs, matcher.rs).
  • (testing feature) Expose test_schemas::schemas() (the fixture this crate’s own tests, its fuzz targets, and its checked-in fuzz corpus all compile predicates against) to external consumers, most notably the crate’s own tests/corpus_replay.rs, which links blockwatcher-expr as a dev-dependency with testing enabled specifically to reach it.

Not this crate’s job: defining the Matcher trait, SchemaSet, or ValueType it type-checks against (blockwatcher-ports, blockwatcher-types); choosing which matcher module a deployment runs (blockwatcher.toml, read by blockwatcher-core); shipping a second predicate language: none exists, and nothing about the port boundary requires this one.

Key types and traits

NameKindRole
PredicatestructA predicate that already passed parsing and type-checking; compile is its sole constructor, which is what lets matches run without any failure path
ExprMatcherstructThe blockwatcher-ports::Matcher implementation wrapping Predicate
Registry, ExprConfigstructThe ModuleRegistry registration for "expr"; ExprConfig is an empty, deny_unknown_fields struct, since the module takes no configuration of its own
Ast, Nodestruct/enumThe parsed tree: a Node paired with the byte Span it was parsed from
BinOpenumThe 17 binary operators the grammar admits
FamilyenumThe type-checker’s own, coarser type vocabulary (Int/Str/Bytes/Bool/Array/Map): Int collapses ValueType::Int/Uint, Bytes collapses Address/Bytes
NodeTypestructA node’s computed type: its Family set, whether every integer declaration behind a path is Uint-only, and a literal’s constant value (the state families/bin_type thread through the type-check walk)
TruthenumThe evaluator’s three-valued result: True / False / Unknown

How data flows through it

flowchart LR
    S["source text"] -->|"lexer::lex"| T["tokens"]
    T -->|"parser::parse"| A["Ast<br/>(untyped)"]
    A -->|"typecheck::check(schemas)"| P["Predicate<br/>(typed AST, compiled)"]
    P -->|"eval::evaluate(event)"| Tr["Truth"]
    Tr -->|"== Truth::True"| M["matches(): bool"]
    A -.->|"lex/parse/type error"| E["PredicateError"]

typecheck::check does not transform the tree: it walks the same Ast parser::parse returned and either accepts it or fails with a PredicateError; the “typed AST” in the diagram above is that same tree plus the guarantee that every path resolved and every operator’s operands admitted it. Predicate::compile (lib.rs) runs the whole left half of this diagram in order (parse, then check), and only a result that survived both becomes a Predicate; matches (lib.rs) is the whole right half, eval::evaluate(&self.ast, event) == eval::Truth::True, over the canonical Value tree a DecodedEvent carries. explain and referenced_fields are separate walks over the same typed Ast: the former re-evaluates and renders the failing subtree, the latter just collects every Node::Path. Neither one is on this diagram’s critical path, since both run only when a caller asks for them.

Neighbours

blockwatcher-expr depends on the following in production:

  • blockwatcher-types: vocabulary and schemas
  • blockwatcher-ports: trait boundary for matcher port
  • serde: (de)serialization derive
  • serde_json: JSON wire format
  • num-bigint: arbitrary-precision integers

In [dev-dependencies]:

  • blockwatcher-expr (at path = "." with testing feature): allows tests/corpus_replay.rs to reach test_schemas::schemas() under bare cargo test
  • blockwatcher-ports (fakes feature): FakeMatcher for cross-checking in matcher.rs tests
  • indexmap: ordered maps
  • proptest: property testing
  • tokio: async runtime

Only the following crate depends on it directly (per the dependency table):

  • blockwatcher-embed: the composition façade registers the matcher into the engine’s module catalog; the blockwatcher binary reaches this crate only through embed

Reading the source

  1. Start at lib.rs: the module list, Predicate’s public surface, and the doc comment on the compile/evaluate field-addressing contract every decoder must honor (pinned end to end by tests/dotted_field_addressing.rs).
  2. lexer.rs: Token, Span, and lex; token-decimal expansion and the two string escapes it recognizes.
  3. parser.rs: Ast, Node, BinOp, the recursive-descent grammar, and the three compile-time bounds (MAX_DEPTH, MAX_TREE_HEIGHT, MAX_SOURCE_LEN), each checked right where a hostile input could exceed it rather than by a separate validation pass afterward.
  4. typecheck/mod.rs: Family, NodeType, families/bin_type (the per-operator-class admission rules), and check, the entry point Predicate::compile calls.
  5. typecheck/diagnostics.rs: every rejection message’s exact wording and the bounded edit-distance search behind the did-you-mean suggestion.
  6. typecheck/message_guard.rs (cfg(test) only): a falsification harness that reads every rejection message as a claim and checks it against every predicate the checker accepts, so a message cannot assert something an accepted predicate disproves.
  7. eval.rs: Truth, the three-valued Kleene evaluate, and explain/explain_failed, the dry-run diagnostic walk.
  8. matcher.rs: ExprMatcher (the Matcher port implementation), Registry/ExprConfig (the "expr" module registration), and matchers::get_all().
  9. test_schemas.rs: the schema fixture behind the testing feature, shared by this crate’s own tests, its proptest suites, its fuzz targets, and tests/corpus_replay.rs.

blockwatcher-core

blockwatcher-core is blockwatcher’s engine: it assembles a running pipeline out of a network’s already-constructed source, its chain’s decoder, and the engine’s one matcher; tracks every dispatched match until it is either delivered or dead-lettered; decides the checkpoint that tracking makes safe to persist; and is the one path (ControlHandle) by which a resource write, delete, pause, dry run, or dead-letter replay reaches a running deployment. It is the largest crate in the workspace, and this is its largest page.

Its production dependencies are exactly the names scripts/check-dep-graph.sh’s ALLOW_BLOCKWATCHER_CORE entry lists:

  • blockwatcher-types: the vocabulary every resource, event, and identifier this crate moves is expressed in
  • blockwatcher-ports: the port traits spawn’s tasks drive (Source, Decoder, Matcher, Gate, Sink, Storage)
  • serde: the derive machinery behind every (de)serializable shape in the crate
  • serde_json: every resource’s on-the-wire and in-storage JSON representation
  • thiserror: the derive behind EngineError (error.rs)
  • tokio (macros/rt/time features): the async runtime every task, channel, and timer in the crate runs on
  • tokio-util: CancellationToken, the primitive spawn’s whole cancellation topology (cancel, hard_cancel, drain_token) is built from
  • futures: FutureExt::catch_unwind, used by the source task wrapper to catch a panicking source without taking the pipeline down with it
  • metrics: the facade every named constant in metrics.rs emits through
  • tracing: every structured log line in the crate

No chain SDK, HTTP client, or storage driver appears anywhere in this list or its transitive tree. blockwatcher-core also carries no family exemption at all in that same script’s family_exemptions_for (family_exemptions_for in scripts/check-dep-graph.sh): nothing forbidden may appear anywhere in its transitive tree under any named carve-out. See Architecture decisions § Chain knowledge stays out of the core and Workspace map § Verifying the rings for the rule this crate is one example of.

The pipeline and Delivery guarantees already describe this crate’s pipeline at an operator’s level: one task per stage, the backpressure it enforces, the guarantee a checkpoint carries. This page goes one level deeper: every file inside pipeline/ and control/ mapped to what it owns, the exact channel and cancellation-token wiring spawn builds, and the checkpoint writer’s own retry state machine. Where the two levels overlap, this page links to the concept page rather than restating it.

Key takeaways

  • blockwatcher-core is the engine: it assembles one pipeline per network from a source, a decoder, the one matcher, and an optional per-monitor gate, and is the only path (ControlHandle) through which a resource write reaches a running deployment.
  • No chain SDK, HTTP client, or storage driver appears anywhere in its dependency tree, and it carries no family exemption at all.
  • It is the largest crate in the workspace, depending only on blockwatcher-types and blockwatcher-ports among workspace crates.
  • This page goes one level deeper than the pipeline and delivery-guarantees concept pages: every file inside pipeline/ and control/, the exact channel and cancellation wiring spawn builds, and the checkpoint writer’s own retry state machine.

Responsibilities

  • Assembles and runs one pipeline per network: a single function, pipeline::spawn, constructs every channel and cancellation token a pipeline needs and then starts a source task, a processor task, one sink worker task per sink any of that network’s monitors reference, and a checkpoint writer task on top of them (crates/blockwatcher-core/src/pipeline/mod.rs). See Pipeline workers below for how every file in pipeline/ maps to one of those tasks.
  • Owns the whole control plane: Engine::start/Engine::shutdown for boot validation and a deadline-bounded drain, and ControlHandle for every resource write, delete, pause/resume, dry run, checkpoint skip, and dead-letter replay a running deployment accepts (engine.rs, control/). See The control plane.
  • Tracks every dispatched match until it is delivered or dead-lettered, and derives from that the one cursor a network’s pipeline may safely persist, through Progress/CompletionGuard (progress.rs) and CheckpointWriter (pipeline/checkpoint_writer.rs). See Progress and checkpointing.
  • Compiles a monitor’s selectors and predicate, and a chain’s specs, against the live decoder and the engine’s one matcher instance, once at write time, never on the event path (compile.rs, compiled.rs).
  • Names every module family a config can select from and reports an actionable refusal, listing every registered alternative, for a name nothing registered (catalog.rs).
  • Fans every pipeline event out to two independent observability destinations: the atomic PipelineCounters a status read serves with no cardinality cost (counters.rs), and the metrics facade constants an exporter may or may not be listening to (metrics.rs). See blockwatcher-metrics for the side of that pair which actually exports anything.
  • Reads and type-validates every stored resource before anything downstream ever sees it (resources.rs), and reports a point-in-time snapshot of every running, paused, or abandoned pipeline (status.rs).

Not this crate’s job: deciding what a chain’s wire format means, or implementing any source, decoder, sink, or storage module: blockwatcher-evm, blockwatcher-storage, and blockwatcher-sinks each own exactly one, behind the port traits blockwatcher-ports declares; running the predicate language itself (blockwatcher-expr: this crate only calls Matcher::compile/matches through the port); exposing anything over HTTP (blockwatcher-api wraps ControlHandle in routes) or Prometheus (blockwatcher-metrics installs the recorder this crate’s metrics.rs module writes into, and serves the scrape endpoint); defining the port traits it drives or the vocabulary types it moves (blockwatcher-ports, blockwatcher-types).

Key types and traits

NameKindRole
Engine, EngineDepsstructThe running engine: one pipeline per network, boot validation, and a deadline-bounded shutdown drain; EngineDeps is what Engine::start takes by value (storage, module catalog, config)
Engine::start, Engine::shutdown, ShutdownReportfn / structBoot’s all-or-nothing validate-then-spawn pass, and shutdown’s per-pipeline drain report (drained vs. aborted)
ControlHandlestructThe one path a running engine’s resources change through; every mutating method holds Engine’s single mutation lock for its entire duration
EngineErrorenumEvery refusal the engine or a mutation can produce, each variant carrying the identity of what it happened to
EngineConfig, SourceRestartstructBoot-time instance configuration (channel capacities, drain deadline, journal_depth, default retry policy) and the restart supervisor’s backoff policy
ModuleCatalogstructName-keyed home for every module family’s factories; a name nothing registered fails as UnknownModule, listing every alternative that was
Resources, Versioned<T>structTyped, validated reads over the primitive Storage port; Versioned pairs a value with the optimistic-concurrency version it was read at
CompiledMonitor, MonitorSetstructOne monitor’s write-time artifacts (compiled selector, predicate, actions), and a network’s whole compiled set plus its runtime pause set and merged source interest
compile_monitor, build_monitor_set, spec_set_for_chainfnCompiles one monitor / unions a network’s interest and referenced-fields hints across its monitors / compiles every spec on a chain: all write-time, never on the hot path
Progress, CompletionGuardstructThe contiguous-prefix tracker that derives the checkpoint a pipeline may safely persist; see Progress and checkpointing
PipelineCounters, CounterSnapshotstructPer-pipeline atomic event counts, and the point-in-time serializable copy GET /status (and this crate’s own metrics module) reads from
EngineStatus, PipelineStatus, QueueDepth, SourceStatusViewstruct / enumThe GET /status response shape: one entry per running, paused, or abandoned pipeline, its queue depths, and a serializable mirror of blockwatcher_ports::SourceStatus
TestInput, TestOutcome, TestReport, MAX_TEST_INPUTSenum / struct / constThe monitor dry-run’s request, per-input result, and whole-report shapes, and the shared input cap both this crate and the API refuse past
SkipReport, SkipTargetstruct / enumWhat skip_network moved a paused network’s checkpoint/start_block to, and where it was told to move it (a confirmed tip, or an absolute block)
NO_REPLAY_PAYLOAD, NO_REPLAY_RETRACTEDconstThe exact refusal messages for replaying a dead letter recorded before payload storage existed, and for a letter whose payload is a retraction; matched verbatim by the API layer

Pipeline workers

pipeline/ holds one file per pipeline stage plus the glue between them: nothing in the directory is shared infrastructure unrelated to running one network’s pipeline:

File-by-file map: pipeline/
FileOwns
mod.rsspawn itself, PipelineSpec (what a caller hands in), PipelineHandle (what a caller gets back), ChannelGauge<T> (a status-readable, non-owning view onto a bounded channel), and SourceExit (the restart supervisor’s own signal). This is the assembly file: every channel, every cancellation token, and every task the other files’ types run inside is created here, once, in spawn (pipeline/mod.rs).
processor.rsProcessor: the decode-and-match stage. One task per network, consuming raw events and, for every active, unpaused monitor, running the decoder against that monitor’s compiled selector and the matcher against its compiled predicate (processor.rs). Gate arithmetic is not inlined here: pipeline/gate.rs applies the decision, persists the journal, and mints from indices.
gate.rsApply a compiled gate’s on_hit decision, persist gate_hits/gate_meta, mint Match/Digest from returned indices. Processor calls it; window math lives in blockwatcher-gates.
sink_worker.rsSinkWorker and WorkItem: the delivery stage. One task per sink a network’s monitors reference; the retry attempts, the backoff between them, and the eventual dead letter all live here, in one place, so a webhook sink and a script sink fail exactly the same way regardless of what either one’s own module code does (sink_worker.rs).
aggregate.rsAggregateBuffer: the per-sink aggregation window. It owns what is held and until when; delivering, journaling, and dead-lettering what it hands back stays the sink worker’s job, identically for a digest and for a lone match (aggregate.rs).
throttle.rsThrottleWindow: the per-sink admission window. It owns how much of the budget is left and until when; which events answer to the budget, and what becomes of one it refuses, stays the sink worker’s job, which dead-letters a refusal on the same terms it dead-letters a failed delivery (throttle.rs).
delivery.rsdeliver_with_retry and DeliverOutcome: the retry loop itself, factored out rather than duplicated at each call site: SinkWorker::run calls it for live traffic, and ControlHandle::replay_dead_letter (control/dead_letters.rs) calls the identical function again for an operator-triggered replay. Neither caller gets its own copy of the retry-count/backoff-doubling arithmetic, or its own idea of which ErrorClass values are worth a second attempt (delivery.rs).
checkpoint_writer.rsCheckpointWriter and DrainSignal: the persistence stage. One task per network, persisting whatever Progress publishes and refusing anything older than what this instance already wrote (checkpoint_writer.rs). See Progress and checkpointing for what it actually does.

progress.rs sits one directory up from all of them, deliberately: Progress is not a task, it is a shared, lock-protected structure the processor registers events into and sink workers complete guards against. See Progress and checkpointing.

Pipeline worker topology

spawn (pipeline/mod.rs) builds every channel, watch, and cancellation token a pipeline needs before it spawns a single task, and every task below is handed only the subset it actually touches:

flowchart TD
    handle["PipelineHandle<br/>(held by Engine)"]

    subgraph one["one spawn() call"]
        src["Source task<br/>ctx.events (Sender)<br/>ctx.interest / ctx.status (watch)<br/>ctx.cancel"]
        proc["Processor task<br/>reads events_rx<br/>reads set_rx (watch)<br/>writes Progress.begin<br/>writes sink senders"]
        sw["Sink worker task<br/>(one per sink)<br/>reads its own WorkItem receiver<br/>completes guards via Progress<br/>cancel = hard_cancel"]
        cpw["Checkpoint writer task<br/>reads checkpoint_rx (watch)<br/>reads drain_token<br/>cancel = hard_cancel"]
        prog[("Progress<br/>shared struct, not a task")]
    end

    src -->|"events_tx: mpsc(event_channel_capacity)<br/>pipeline/mod.rs"| proc
    proc -->|"sink_tx: mpsc(sink_channel_capacity)<br/>one per sink, pipeline/mod.rs"| sw
    proc -->|"begin(cursor, outstanding)"| prog
    sw -->|"guard.complete()"| prog
    prog -->|"checkpoint_rx: watch&lt;Option&lt;Checkpoint&gt;&gt;"| cpw
    cpw -->|"put_checkpoint"| store[("checkpoint<br/>blockwatcher-storage")]

    handle -->|"set_tx.send_replace<br/>(hot swap)"| proc
    handle -->|"interest_tx.send_replace<br/>(hot swap)"| src
    src -->|"status_tx.send_replace"| handle

    parent(("parent_cancel<br/>engine root")) -.->|".child_token()"| src
    hard(("hard_cancel<br/>independent root,<br/>only a deadline<br/>escalation fires it")) -.-> sw
    hard -.-> cpw
    proc -.->|"Arc&lt;DrainSignal&gt; canary<br/>dropped on task exit"| drain{{"drain_token"}}
    sw -.->|"canary dropped<br/>on every worker's exit"| drain
    drain -.->|"cancelled() once<br/>every canary has dropped"| cpw

    classDef token fill:none,stroke:#e39a3a
    class parent,hard,drain token

Two details that are easy to miss reading the concept-level diagram alone:

  • The bounded mpsc channels are the only queues in this diagram. Every other arrow is a tokio::sync::watch (publishes only the latest value, never queues several) or a direct call against Progress’s own mutex-guarded state. See Backpressure below.
  • hard_cancel is not a descendant of parent_cancel, and the source task never holds it at all. Only the sink workers and the checkpoint writer check it; only a deadline escalation inside drain_pipeline (engine/drain.rs, see Boot, restart, and shutdown) ever fires it. If the pipeline’s own ordinary shutdown signal (cancel) triggered it directly instead, a sink worker or the checkpoint writer would give up on work still sitting in a bounded channel that an un-escalated drain, given the time it is normally owed, would have gone on to flush, per spawn’s own comment (pipeline/mod.rs).
  • The drain-signal canary is held by the processor and every sink worker, never by the source. DrainSignal’s Drop impl fires drain_token once every clone of it has dropped (checkpoint_writer.rs); the checkpoint writer’s own Progress clone, and PipelineHandle’s, would otherwise keep Progress’s watch channel open forever, since neither ever drops for the life of the handle. Excluding the source is deliberate too: a source exiting has nothing to do with whether the processor and sink workers still have buffered work to finish draining.

Backpressure: the bounded channels

Between source and processor sits one tokio::sync::mpsc channel of capacity event_channel_capacity (default 256); between processor and each sink worker sits one of capacity sink_channel_capacity (default 64), both configured once in EngineConfig (config.rs) and created inside spawn (pipeline/mod.rs). These are the only bounded channels a pipeline has. Nothing else in the topology above can exert backpressure: Progress’s internal VecDeque<Slot> (progress.rs) has no bound at all (it grows with however many events are in flight and shrinks as their guards complete), and every watch channel (set_tx/set_rx, interest_tx/interest_rx, status_tx/status_rx, checkpoint_rx) holds at most one pending value by construction: a send_replace overwrites whatever was there, it never queues a second one behind it. A hot-swapped monitor set or a republished checkpoint can never itself pile up; only the mpsc channels above can.

Both channel operations are ordinary tokio::sync::mpsc::Sender::send calls with nothing wrapped around them: no try_send, no timeout, no fallback path (the sink dispatch call site is processor.rs). That is the entire mechanism: a full channel just makes that one .await pend. The pipeline § Backpressure: two bounded channels, no drops walks through where that pending call propagates to (the processor’s whole dispatch loop, then the source’s own send) and Delivery guarantees covers why a stall is the accepted outcome rather than a drop. This page’s own contribution is the inventory above: the bounded points it names are the only ones in the entire topology, and their configured capacities are the only backpressure knobs EngineConfig exposes: nothing else in spawn’s wiring can ever be the thing that is full.

Progress and checkpointing

Delivery guarantees already names Progress as the mechanism deciding when a cursor becomes safe to persist; this section is the data structure and algorithm underneath that decision. Progress’s state (State, progress.rs, held inside Inner alongside the watch Sender, progress.rs) is a VecDeque<Slot> behind one Mutex: each Slot (progress.rs) carries one raw event’s cursor, its source_state, and a remaining count of matches dispatched from it that have not yet completed. begin (progress.rs) pushes one Slot per raw event, in the order the processor calls it, and nothing ever removes a slot except from the front. advance_locked (progress.rs) is the entire algorithm on top of that queue: walk forward from the front for as long as Slot::remaining is zero, drop every slot that check passes, and, only if at least one slot was dropped, call send_replace with the cursor of the last one. Running the walk and the send_replace inside the same critical section is what rules out two concurrent completions racing each other into the watch channel in the wrong order; a version that unlocked between the two could have one thread publish cursor 3 and a second thread, scheduled just behind it, publish cursor 1 afterward, and nothing would ever notice or correct it since the slots behind both are already gone.

CompletionGuard::complete (progress.rs) is Slot::remaining -= 1 followed by the same advance_locked call; dropping a guard without calling complete (a worker task dying mid-delivery, most notably) leaves that slot’s remaining nonzero forever, which permanently stalls the prefix at that slot rather than expiring or timing out. Delivery guarantees § The contract, precisely covers what that guarantee means to a consumer; the mechanism above is what it compiles down to.

CheckpointWriter (checkpoint_writer.rs) is the other half: a task per network that watches Progress’s publish channel and persists whatever it sees, forever retrying a failed write against the same value. Persisting whatever the watch currently holds, rather than a queued history of values, is safe specifically because of how advance_locked publishes: since every publish happens while holding the same lock a later publish must also acquire, a later value can never be an earlier cursor than one already sent. One rule this task adds on top of Progress’s own monotonicity: persist (checkpoint_writer.rs) tracks last_persisted within this writer instance’s own lifetime and refuses (counts, logs, and skips) any checkpoint older than that, independent of what Progress would otherwise publish. That guard exists for a source that violates its own non-decreasing-cursor contract mid-run; it is explicitly not enforced across a restart, because a fresh writer’s first persist is unconditional: the mechanism Boot, restart, and shutdown below relies on to make a legitimate rewind (a positively detected reorg, a resumed process) possible at all.

stateDiagram-v2
    [*] --> Waiting
    Waiting --> Waiting: sample tick (every 1s)<br/>gauge IN_FLIGHT_EVENTS
    Waiting --> CheckRegression: checkpoint_rx.changed()<br/>or drain fires<br/>(borrow_and_update reads the latest value)
    CheckRegression --> Waiting: cursor behind last_persisted<br/>refused, counted<br/>(CHECKPOINT_REGRESSIONS_REFUSED)
    CheckRegression --> Persisting: cursor at or past last_persisted
    Persisting --> Persisted: storage.put_checkpoint Ok<br/>last_persisted = cursor
    Persisted --> Waiting: loop, unless drain already fired
    Persisted --> [*]: drain fired, nothing left to flush
    Persisting --> Backoff: storage.put_checkpoint Err<br/>counted (CHECKPOINT_WRITE_FAILED)
    Backoff --> Persisting: backoff elapses<br/>(doubles, capped at max_backoff_ms)
    Backoff --> Abandoned: hard_cancel fires<br/>(deadline escalation only)
    Abandoned --> [*]
    Waiting --> [*]: drain fired,<br/>nothing pending to flush

Two states are worth naming precisely against the source: CheckRegression is persist’s own comparison against last_persisted (checkpoint_writer.rs), and it is the only place a value is discarded rather than eventually written: a discard here is never silent, it increments PipelineCounters::checkpoint_regressions_refused and the matching metrics.rs constant, and logs at error level naming both cursors. Abandoned is reachable only from Backoff, and only because hard_cancel fired: the writer never gives up on a value it has already decided to persist for any other reason, including the pipeline’s own ordinary cancel token, which this task does not even hold a reference to.

The control plane

blockwatcher-core’s own module comments draw the same distinction this section does: control/ is “the caller-facing half — it owns the validate/compile/ persist ordering, the optimistic concurrency, and the mutation lock” (engine/control.rs:4-6), while engine/control.rs (a different file, one level up from control/) is “the engine-side half,” owning what happens to the running pipeline once a mutation has already validated and persisted its change (engine/control.rs:1-2).

control/: the caller-facing half

One file per method family on ControlHandle:

File-by-file map: control/
FileOwns
mod.rsControlHandle itself; pause_monitor/resume_monitor and pause_network/resume_network: the persisted lifecycle toggles, see below; status(); and network_context, the shared resolve-network-then-decoder-then-specs helper every method that acts on one monitor uses (control/mod.rs).
writes.rsput_monitor, put_network, put_sink, put_spec, and restart_affected: creation and update for every resource kind, each validating references and compiling before persisting, then restarting or hot-swapping whatever running state the write affects (control/writes.rs).
deletes.rsdelete_monitor, delete_network_checkpoint, delete_network, delete_sink, delete_spec, and refuse_if_referenced: removal, and the one rule this family owns on its own: a resource any stored monitor still names is refused before storage is touched at all (control/deletes.rs).
inspect.rstest_monitor (the dry run: TestInput/TestOutcome/TestReport, MAX_TEST_INPUTS) and spec_schema: the read-only surface, taking no mutation lock and touching nothing running (control/inspect.rs).
skip.rsskip_network, SkipTarget, SkipReport: the operator “skip catch-up” patch: moves a paused network’s checkpoint and source start_block forward without replaying the gap, deliberately bypassing put_network (which would clear the pause and restart) (control/skip.rs).
dead_letters.rslist_dead_letters, replay_dead_letter, find_dead_letter, and NO_REPLAY_PAYLOAD: a storage read and an operator-triggered re-entry into deliver_with_retry (see Pipeline workers above) (control/dead_letters.rs).

Every mutating method here follows the same order, stated once in control/mod.rs’s own module doc comment and never varied: “validate references, compile or construct whatever needs it, persist with optimistic concurrency, then update whatever is actually running — in that order, always, for every mutating method, writes and deletes alike” (control/mod.rs:65-69). A write or delete that has already persisted is treated as done regardless of what happens next: whatever running state it still has to touch (a republished monitor set, a restarted pipeline) is attempted best-effort, and a failure there is logged with the resource and reason rather than turned into an error response for a resource storage already durably holds.

Pause is persisted

Both pause surfaces, ControlHandle::pause_monitor/resume_monitor and pause_network/resume_network (control/mod.rs), persist the pause to storage before touching anything running, and every path that builds a monitor set or boots a pipeline reads that persisted state back:

  • Pausing a monitor writes its id to storage’s paused-monitor set (ControlHandle::set_paused, control/mod.rs), then republishes the network’s running monitor set through build_monitor_set with that same persisted set (Engine::republish_paused_monitors, engine/control.rs). The persist is the request: a pipeline that is not running, or a republish that fails, is logged and left for the next rebuild, which reads the persisted set from scratch and always applies it — the same path a network, sink, or spec write’s restart takes, and the same one boot itself seeds from (engine/boot.rs). Deleting a monitor clears its pause row with it (control/deletes.rs).
  • Pausing a network persists the pause (Storage::set_paused), marks it in Engine.paused_networks (a HashSet<NetworkId>, engine.rs) so a concurrent SourceExit cannot race a restart back into existence, then stops its pipeline through the same bounded drain a restart uses. Resuming clears the persisted row and restarts the pipeline unless one is already running for it; a restart that then fails is logged rather than returned, since the persist already succeeded (resume_network, control/mod.rs). Boot reads the persisted set before constructing any pipeline and never spawns one for a paused network at all, intersected against the networks boot actually built so a pause row that outlived its network cannot render a status entry for one that no longer exists (engine.rs’s constructor). The restart supervisor consults the same in-memory mark (is_paused) and the delete tombstone (is_tombstoned) before ever scheduling a restart of its own, so an operator’s pause is never silently undone by a source exiting and the supervisor bringing it back (engine/supervisor.rs). The tombstone is a HashMap of network id to the sink ids the last pipeline for that network was spawned with, so a late Invalidated after delete still retracts only those sinks. Deleting a network clears its pause row with it.

A monitor’s pause additionally drops out of the source’s merged interest hint the moment it is paused: build_monitor_set excludes a paused monitor’s selector from the interest union it hands the decoder (compile.rs), so pausing a monitor can only ever shrink what its network’s source is asked to fetch, never leave it fetching data nothing will read.

Boot, restart, and shutdown

engine.rs plus its engine/ submodules are the other side of the control plane: what an engine does on its own behalf (boot, shutdown, restart supervision) rather than what a caller’s mutation asks of it.

File-by-file map: engine/
FileOwns
boot.rsvalidate_and_build: the single all-or-nothing pass both Engine::start and Engine::validate run through: parse and type-check every stored resource, build the matcher and one decoder per registered decoder module, construct every sink, check that no monitor names a network, sink, or spec that either doesn’t exist or doesn’t match, and then, network by network, compile its monitors and construct its source and read its checkpoint. A failure on network N never leaves networks 1..N-1 half-validated behind it: nothing is handed to a caller to spawn until the whole pass clears (engine/boot.rs). Also check_checkpoint_provenance, re-exported for control/writes.rs to share (engine/boot.rs).
control.rsbuild_pipeline_spec, restart_pipeline, stop_pipeline, hot_swap_monitors, republish_paused_monitors: everything that happens to a running pipeline once a caller-facing mutation has already validated and persisted its change. Every function here requires Engine’s mutation lock already held by its caller; none of them takes it (engine/control.rs). stop_pipeline also owns the StopOutcome a caller reads to know whether storage is safe to touch next: after an escalated drain it awaits Storage::quiesce under drain::QUIESCE_BUDGET, and restart_pipeline refuses to spawn a replacement (leaving the network abandoned) when that wait times out, rather than read a checkpoint a residual write from the abort could still race.
drain.rsdrain_pipeline: the shared teardown ladder both Engine::shutdown and a single-network stop_pipeline/restart_pipeline drain through: await each task to a deadline, then hard_cancel, a brief grace window, then a forced abort() for any straggler, counted under PIPELINES_ABORTED the moment any escalation was needed at all (engine/drain.rs). Also QUIESCE_BUDGET, the bound both Engine::shutdown and stop_pipeline apply to Storage::quiesce right after an escalation, so an aborted task’s detached storage call is waited out (or, on shutdown, at least warned about and counted as PIPELINES_QUIESCE_TIMEOUT) before its caller treats the network as clean.
supervisor.rsThe restart supervisor task Engine::start spawns. Reacts to nothing but the SourceExit channel: Invalidated is a control path (drain, retract, rewind, restart via engine/invalidate.rs), not a crash backoff. A failed invalidate is counted as SOURCE_INVALIDATION_FAILURES, not SOURCE_RESTART_FAILURES. Every other exit schedules a restart_pipeline call at a per-network delay that doubles with each consecutive failure and resets once a network has run healthily past a configurable interval, and it drops an exit for a network that is already paused or tombstoned on the spot rather than counting it as anything (engine/supervisor.rs).
invalidate.rshandle_invalidation: cooperative drain, prune gate_hits where cursor > from (do not delete cursor ≤ from), re-read the checkpoint, journal list_deliveries_after, one-shot retract pass through deliver_with_retry, then rewind only when from is strictly behind the stored checkpoint (a rewind that would advance it, or fabricate one where none exists, is refused, counted under REWINDS_REFUSED, and the checkpoint stays put), and restart. Retracts go to the live pipeline’s sinks, or after delete to the sink ids stored on that network’s runtime tombstone (not every remaining sink resource). An unrecovered retract leaves the checkpoint unrewound (engine/invalidate.rs).

Two things a reader coming from pipeline/mod.rs’s spawn should carry into this section: restart_pipeline’s replacement pipeline is built to completion before the existing one is touched, so a fallible step (a storage error, a module that no longer constructs) always leaves whatever was running before completely untouched; and the replacement’s checkpoint is deliberately read after the old pipeline has drained, never before. A pre-drain read would miss whatever further completions land while the old pipeline winds down, so the replacement would come up already behind its own predecessor and, on its first persist, push the stored cursor backward (engine/control.rs, doc comment). This is the one place in the whole crate a restart is allowed to make a checkpoint “rewind” in CheckpointWriter’s sense above: the fresh writer’s first persist is unconditional, precisely so this legitimate case is not caught by the same guard that refuses a source’s own contract violation.

Neighbours

blockwatcher-core depends on, in production (the same list named in full above, and no chain SDK, HTTP client, or storage driver anywhere in this list or its transitive tree (see the exemption-free guarantee described above):

  • blockwatcher-types
  • blockwatcher-ports
  • serde
  • serde_json
  • thiserror
  • tokio (macros/rt/time features)
  • tokio-util
  • futures
  • metrics
  • tracing

and, in [dev-dependencies] only:

  • blockwatcher-ports (fakes feature): FakeSource, FakeDecoder, FakeMatcher, FakeSink, MemoryStorage, FlakyStorage; every test in this crate runs against these, never a real module
  • blockwatcher-testkit: RecordingMetrics, used to assert on what a pipeline flow emits through the metrics facade
  • async-trait: implementing Source/Sink test doubles (PanickingSource, CancelThenOkSource, SlowSink) that blockwatcher-ports::fakes doesn’t already provide
  • tokio (rt-multi-thread/test-util features): multi-threaded and paused-time async tests

The following crates depend on it directly (per the dependency table):

  • blockwatcher-api: wraps every ControlHandle method in an HTTP route
  • blockwatcher-embed: calls Engine::start for in-process hosts and for the binary’s own boot
  • blockwatcher (binary): owns the process’s own shutdown signal, and calls Engine::validate for its offline config-check mode

Reading the source

  1. Start at lib.rs: the module list and the full re-export surface; every name in the key types table above is pub re-exported here (CheckpointWriter is the one type this page covers in depth that is not: mod pipeline; has no pub, so nothing outside blockwatcher-core can name it, pub struct on the type itself notwithstanding).
  2. error.rs: EngineError, every variant naming the identity of what it happened to; read this before anything else, since almost every fallible function in the crate returns it.
  3. config.rs: EngineConfig and SourceRestart, and their defaults (event_channel_capacity: 256, sink_channel_capacity: 64, drain_deadline_ms: 10_000, journal_depth: 1024).
  4. catalog.rs: ModuleCatalog, and the family! macro that generates one register/lookup pair per port family so registering, say, a sink factory as a source is a compile error.
  5. compiled.rs, then compile.rs: CompiledMonitor/MonitorSet, and the three write-time functions that build them: compile_monitor (selectors, predicate, and gate), build_monitor_set (read its doc comment on field-union poisoning closely: one un-introspectable monitor’s predicate makes the whole network’s referenced-fields hint unknown, on purpose), and spec_set_for_chain.
  6. resources.rs: Resources/Versioned<T>, and why a listing (load) fails whole on one bad record while a by-id read (load_one) fails only the lookup that names it.
  7. counters.rs, then metrics.rs: PipelineCounters/CounterSnapshot, then the named Prometheus constants and the emission helpers (count, count_sink, count_gate, gauge) every pipeline stage calls alongside its matching atomic increment. Gate metric names (blockwatcher_gate_hits_total and the rest) are the same strings Observability lists. Invalidate, retract, and journal-gap counters live here too; they have no PipelineCounters twin. See blockwatcher-metrics § From a pipeline event to a scrape line for where the second half of that pair actually goes.
  8. progress.rs: Progress/CompletionGuard/Slot/advance_locked; read this before pipeline/checkpoint_writer.rs, which depends on everything it guarantees.
  9. pipeline/mod.rs, then processor.rs, sink_worker.rs, delivery.rs, checkpoint_writer.rs, in that order. See Pipeline workers above for what each one owns.
  10. engine.rs: Engine, EngineDeps, ShutdownReport, and the mod declarations for its own submodules; read Engine::start and Engine::shutdown here before descending into engine/boot.rs, engine/control.rs, engine/drain.rs, engine/supervisor.rs, and engine/invalidate.rs. See Boot, restart, and shutdown above for what each one owns.
  11. control/mod.rs, then writes.rs, deletes.rs, inspect.rs, skip.rs, dead_letters.rs. See The control plane above for what each one owns, and read mod.rs’s module doc comment first: it states the validate-persist-swap ordering and the post-persist contract every one of the other files holds to.
  12. status.rs: SourceStatusView, QueueDepth, PipelineStatus, EngineStatus, and the small pure functions (head_of, lag_between, queue_depth_with_capacity) Engine::status_snapshot composes them from.

blockwatcher-metrics

blockwatcher-metrics is blockwatcher’s Prometheus wiring: it installs the process-global recorder that every metrics facade call anywhere in the workspace writes into, and it serves the /metrics scrape endpoint that renders whatever that recorder has accumulated since process start. It is a module crate in the sense the workspace map uses the word: not because it varies chain behavior, but because it is the one crate in the workspace allowed to carry an HTTP stack for this purpose, so that nothing in the core ring has to.

Its production dependencies are exactly the packages scripts/check-dep-graph.sh’s ALLOW_BLOCKWATCHER_METRICS entry lists, and no workspace crate at all (crates/blockwatcher-metrics/Cargo.toml; confirmed independently by the dependency graph, where blockwatcher-metrics is one of the nodes in the whole table with no outgoing edge):

  • axum: the HTTP framework serve’s /metrics router runs on
  • metrics-exporter-prometheus: PrometheusBuilder/PrometheusHandle and the Prometheus recorder itself
  • thiserror: the derive behind MetricsError
  • tokio (net/time/sync/macros/rt features): the listener, the upkeep interval, and the shutdown-signal plumbing
  • tracing: the crate’s own warning-level logging

That absence of any workspace crate is the point: blockwatcher-core’s own metrics.rs module calls the bare metrics facade crate directly (a header-only dependency with no network stack of its own) and never touches axum or metrics-exporter-prometheus. Only this crate does, which is exactly what scripts/check-dep-graph.sh checks: its ALLOW_BLOCKWATCHER_CORE entry names metrics but not axum or metrics-exporter-prometheus, and carries no family exemption at all, so neither could appear anywhere in blockwatcher-core’s transitive tree either; its ALLOW_BLOCKWATCHER_METRICS entry names both, with FAMILY_EXEMPT_BLOCKWATCHER_METRICS="axum hyper tower-http" covering the transport crates axum itself pulls in. Splitting the exporter from every emitter is what lets a pipeline task keep calling metrics::counter! for free while the HTTP server, its router, and its accept loop live in a crate the core ring never has to compile. See Workspace map § Verifying the rings and Architecture decisions § Chain knowledge stays out of the core for the general rule this crate is one instance of.

This page covers how the exporter itself works (installing the recorder, serving the scrape route, running upkeep) and how the two per-pipeline observability destinations blockwatcher-core maintains relate to it. Observability already documents every exported metric’s name, type, and label from an operator’s point of view; this page does not repeat that table.

Key takeaways

  • blockwatcher-metrics installs the process-global Prometheus recorder every metrics facade call writes into, and serves the /metrics scrape endpoint that renders it.
  • It is the one crate in the workspace allowed to carry an HTTP stack for this purpose, so nothing in the core ring has to.
  • It depends on no workspace crate at all; blockwatcher-core’s own metrics.rs calls the bare metrics facade directly and never touches axum or the Prometheus exporter.

Responsibilities

  • Installs the process-global Prometheus recorder exactly once per process, via PrometheusBuilder::new().install_recorder(), and hands back the same PrometheusHandle on every later call in the same process rather than erroring or reinstalling: the metrics crate has no uninstall, so cumulative series continue across a repeated call instead of resetting (install_recorder, crates/blockwatcher-metrics/src/lib.rs).
  • Serves GET /metrics on an already-bound TcpListener as Prometheus exposition text (text/plain; version=0.0.4; charset=utf-8). Alongside the route itself, serve spawns a second task that calls handle.run_upkeep() every 5 seconds and keeps calling it for the whole time the listener is up. See Observability § Turning it on for what an operator gets out of that (serve, router, lib.rs).
  • Stops accepting new connections the moment shutdown resolves but lets an in-flight scrape finish, via axum::serve(..).with_graceful_shutdown(..) (lib.rs).
  • Owns axum so nothing else in the workspace has to. The doc comment at the top of lib.rs makes the point directly: owning axum there, rather than in the binary, is what keeps the HTTP framework out of the binary’s own dependency graph (lib.rs).

Not this crate’s job: deciding what gets emitted, under what name, or with what labels: every metric constant and its call site lives in the crate that owns the event (blockwatcher-core’s metrics.rs for pipeline events, blockwatcher-rpc’s connection pool for its own, per Observability § Other metrics on this endpoint); binding the listener or reading [metrics].listen: the blockwatcher binary does both and hands this crate an already-bound socket, in boot and serve_metrics (crates/blockwatcher/src/run.rs); the control-plane HTTP API, a wholly separate axum::Router on its own socket (blockwatcher-api); mapping a pipeline counter onto anything (see How data flows through it below; this crate is downstream of that mapping, never a party to it).

Key types and traits

NameKindRole
PrometheusHandlestruct (re-export)The installed recorder’s handle; render() produces the exposition text serve’s route returns, and run_upkeep() is what the upkeep task calls every 5 seconds
install_recorderfnInstalls the process-global Prometheus recorder on the first call in a process, or hands back the already-installed handle on every later one
MetricsErrorenumThe one way install_recorder can fail: a foreign recorder already owns the process and no Prometheus handle is available to reuse
serveasync fnRuns the /metrics axum router on an already-bound listener until shutdown resolves, alongside the 5-second upkeep task, and joins that task before returning

How data flows through it

This crate sits downstream of every emitter, never upstream: nothing inside it calls metrics::counter! or metrics::gauge! on its own behalf. install_recorder only makes the process-global recorder exist; serve only renders whatever that recorder has already accumulated by the time a scrape arrives.

flowchart LR
    core["blockwatcher-core<br/>metrics.rs<br/>pipeline/*, engine/drain.rs, engine/invalidate.rs"] -->|"metrics::counter!/gauge!"| rec[("process-global<br/>metrics::Recorder")]
    rpc["blockwatcher-rpc<br/>connection pool"] -->|"metrics::counter!"| rec
    install["install_recorder()<br/>lib.rs"] ==>|"installs once,<br/>returns PrometheusHandle"| rec
    upkeep["upkeep task<br/>every 5s while serve() runs<br/>lib.rs"] -->|"handle.run_upkeep()"| rec
    rec -.->|"handle.render()"| scrape{{"GET /metrics<br/>lib.rs"}}

From a pipeline event to a scrape line

blockwatcher-core maintains two independent, same-call-site destinations for every pipeline event, and only one of them ever reaches this crate:

blockwatcher-core sourceDestinationReaches this crate?
counters.rs’s PipelineCounters plain atomics (the fields on CounterSnapshot)GET /status’s counters object, via CounterSnapshot (crates/blockwatcher-core/src/status.rs, crates/blockwatcher-core/src/engine.rs)No: read directly by Engine::status_snapshot; never touches the metrics facade or this crate at all
metrics.rs’s named Prometheus constants (EVENTS_DECODED, DELIVERIES, SOURCE_INVALIDATIONS, …), incremented via count/count_sink/gauge (crates/blockwatcher-core/src/metrics.rs)The process-global metrics::Recorder this crate installsYes: this is the only path from a pipeline event to a Prometheus series
blockwatcher-rpc’s pool metrics (blockwatcher_rpc_*, crates/blockwatcher-rpc/src/pool.rs)Same process-global recorderYes, the same path, carrying an endpoint label instead of pipeline

Both blockwatcher-core destinations are incremented from the same call site: see, for one example, Processor::process_one’s decode loop, which bumps self.counters.decoded and calls metrics::count(metrics::EVENTS_DECODED, ..) on adjacent lines. Observability § The same events, twice makes the case for why an operator wants both; this page’s narrower point is structural: this crate is the renderer for exactly one of the two rows in that table, and has no view onto the other row’s contents at all: it cannot, since nothing about PipelineCounters ever reaches the metrics facade this crate’s recorder is listening to.

Neighbours

blockwatcher-metrics depends on, in production (no workspace crate among them: crates/blockwatcher-metrics/Cargo.toml; confirmed by the dependency graph):

  • axum
  • metrics-exporter-prometheus
  • thiserror
  • tokio (net/time/sync/macros/rt features)
  • tracing

and, in [dev-dependencies] only:

  • metrics: records a real counter in its own black-box test
  • reqwest: scrapes the served endpoint over real HTTP in that same test

The following crate depends on it directly:

  • blockwatcher (binary): calls install_recorder before seeding and passes the resulting PrometheusHandle to serve_metrics, a thin wrapper spawning blockwatcher_metrics::serve, once [metrics].enabled names a listen address, in boot and serve_metrics (crates/blockwatcher/src/run.rs)

Reading the source

  1. Start at lib.rs’s module doc comment: the one-sentence reason this crate owns axum instead of the binary.
  2. METRICS_HANDLE, MetricsError, and install_recorder: the process-global install-once, reuse-forever contract.
  3. router and serve: the /metrics route, the upkeep task, and the graceful-shutdown wiring that lets an in-flight scrape finish before the listener actually stops.
  4. tests/serve.rs: the crate’s one black-box test, and also its shortest correct usage example: install, record one counter, serve, scrape over real HTTP, shut down, and confirm the port is actually released.

blockwatcher-evm

blockwatcher-evm is the EVM chain family: two sources: evm-rpc, which polls JSON-RPC for confirmed blocks and misses nothing on the chain it watches, and evm-mempool, which subscribes to one node’s pending-transaction feed and reports a transaction before anyone knows whether it will be mined, plus the evm decoder both sources feed (crates/blockwatcher-evm/src/lib.rs). An operator selects one source per network; they are never combined in one pipeline. It is a module crate, built on blockwatcher-rpc’s endpoint pool and the alloy ABI-decoding SDK.

Selectors and Delivery guarantees already document this family at an operator’s level in depth: what events and functions each decode and hand to a predicate, how each source acquires its raw material, each source’s cursor semantics (evm-rpc’s block-number-plus-ordering-bit position; evm-mempool’s per-run arrival counter, its dedupe-by-tx.hash consumer contract, and tx.index being usually-but-not-always absent), and why evm-mempool sits outside at-least-once delivery. This page does not restate any of that: it maps the same facts onto the crate’s actual module layout: which file owns which piece of the pipeline from an RPC endpoint down to a canonical decoded value, the registry that wires it all up, and the decoder’s own compile-once/decode-many mechanics.

Key takeaways

  • blockwatcher-evm is the EVM chain family: two sources, evm-rpc (confirmed blocks, misses nothing) and evm-mempool (pending transactions, no guarantee of mining), plus the one evm decoder both feed.
  • An operator selects one source per network; the two are never combined in one pipeline.
  • This page maps the operator-level facts already documented on Selectors and Delivery guarantees onto the crate’s actual file layout, rather than restating them.

Responsibilities

  • evm-rpc (source::rpc::EvmRpcSource, source/rpc/): poll a pool of JSON-RPC endpoints for confirmed blocks and their logs, detect and recover from reorgs against a bounded recent-block memory, and emit both a block’s logs and, only while some monitor watches functions, its matching transactions, on one non-decreasing cursor stream.
  • evm-mempool (source::mempool::EvmMempoolSource, source/mempool/): subscribe to one node’s newPendingTransactions feed over WebSocket, hydrate each candidate hash’s calldata through the same kind of endpoint pool, and forward it on an arrival-counter cursor with no history and no reorg concept.
  • EvmDecoder (decoder/): translate a Solidity JSON ABI into chain-agnostic schemas exactly once, at spec-compile time, and decode a raw log or transaction against the compiled result on the hot path.
  • Compile a monitor’s selectors into a per-address, per-topic0/per-4-byte-selector dispatch table, and derive the fetch-narrowing interest hints a source may use from it (decoder/selector.rs).
  • Register evm-rpc, evm-mempool, and the evm decoder under the single registration convention every module family follows, and resolve each endpoint’s url_secret into a value neither this crate nor blockwatcher-rpc ever logs or stores (registry.rs).

Not this crate’s job: running a pipeline, retrying a delivery, or deciding when a checkpoint is safe to persist: blockwatcher-core owns all of that; pooling, retrying, or health-checking an RPC connection at the transport level: blockwatcher-rpc owns endpoint selection, circuit breaking, rate limiting, and retry-by-error-class, and this crate only ever supplies the connection type and the error classification blockwatcher-rpc::Pool<C> asks for; parsing an env:NAME secret reference: blockwatcher_types::SecretRef owns that mechanism; this crate only calls it, once, at source construction (see Endpoints and the env:/url_secret indirection below); defining the Decoder/Source port traits or the vocabulary types they move: blockwatcher-ports, blockwatcher-types.

Key types and traits

NameKindRole
EvmDecoderstructThe EVM Decoder port implementation (decoder/mod.rs)
source::rpc::EvmRpcSourcestructThe evm-rpc Source port implementation (source/rpc/run.rs)
source::mempool::EvmMempoolSourcestructThe evm-mempool Source port implementation (source/mempool/run.rs)
EvmIntereststructThe EVM-typed extension of InterestSet::chain_specific: the exact 4-byte function selectors some monitor wants (interest.rs)
jsonrpc::EvmEndpointstructOne JSON-RPC endpoint: a reqwest::Client plus its resolved provider url::Url, the connection type both sources parameterize blockwatcher_rpc::Pool with (jsonrpc.rs)
jsonrpc::EvmRpcErrorenumEvery provider failure this family crosses a boundary as; implements Classify for blockwatcher-rpc’s retry policy (jsonrpc.rs)
source::endpoint::EndpointDefstructOne endpoint’s operator-facing config: name, url_secret, priority, optional rate limit, shared by both sources (source/endpoint.rs)
source::endpoint::EndpointPriorityenumHigh/Low selection tier, the wire form of blockwatcher_rpc::Priority (source/endpoint.rs)
source::rpc::config::EvmRpcConfigstructevm-rpc’s boot-time config: endpoints, start_block, confirmations, lag tolerance, poll interval, logs window, receipt policy (source/rpc/config.rs)
source::rpc::config::ReceiptPolicyenumAlways/WhenRead: when a matching transaction’s receipt is worth fetching for tx.status alone (source/rpc/config.rs)
source::mempool::config::EvmMempoolConfigstructevm-mempool’s boot-time config: subscription URL, hydration endpoints, reconnect interval, and a nested idle_policy (source/mempool/config.rs)
source::mempool::config::IdlePolicyDefstructThe wire form of ws::IdlePolicy: ping_after_ms / pong_deadline_ms, when to suspect a half-open subscription socket and how long to wait for proof (source/mempool/config.rs)
source::rpc::chain::RecentChainstructBounded memory of recently seen (block, hash) pairs, used to verify a fetched window still lines up (source/rpc/chain.rs)
source::rpc::chain::Header, LinkageBreakstructOne block’s number/hash/parent, and the two distinct ways a fetched window can fail to attach: an inconsistent endpoint versus a real reorg (source/rpc/chain.rs)
registry::EvmRpcRegistry, EvmMempoolRegistry, EvmDecoderRegistrystructThe three ModuleRegistry implementations this crate registers (registry.rs)
registry::sources::get_all, registry::decoders::get_all (sources/decoders at crate root)fnFamily enumerations a composition root folds into its module catalog (registry.rs, re-exported lib.rs)

SpecInner, SelectorInner/Entry, EventPlan/FunctionPlan, NamePlan, and every type inside decoder/selector.rs, decoder/plan.rs, and decoder/abi_types.rs are pub(crate) or narrower, reachable only through CompiledSpec/CompiledSelector’s type-erased downcast: none of them is part of this crate’s external surface, regardless of any inner pub marking on the type itself. The same is true of every type in scan.rs (pub(crate), despite scan being a pub mod) and the entire ws.rs module, which sits behind a private mod ws; in lib.rs.

From an RPC endpoint to a canonical value

Both sources and the decoder agree on one boundary type, blockwatcher_types::RawEvent (crates/blockwatcher-types/src/event.rs:17-27):

#![allow(unused)]
fn main() {
pub struct RawEvent {
    pub network: NetworkId,
    pub chain: ChainKind,
    pub cursor: Cursor,
    pub payload: RawPayload,
    /// Per-cursor resume payload, opaque to core: the engine threads it into
    /// the persisted `Checkpoint` and hands it back to the source at resume,
    /// unread and uninterpreted along the way.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub source_state: Option<serde_json::Value>,
}
}

chain: ChainKind is an open string tag ("evm" here, never an enum; see crates/blockwatcher-types/src/id.rs), and payload: RawPayload is either Json(serde_json::Value) or Bytes(Vec<u8>) (blockwatcher-types/src/event.rs). Both sources in this family always produce RawPayload::Json, an untyped log or transaction object taken straight off the wire, never a chain-specific Rust struct. Nothing outside this family’s own decoder ever looks inside that JSON value; the tag is what lets blockwatcher-core route the payload to the one decoder registered for "evm" without itself understanding a single field of it.

flowchart LR
    subgraph endpoint["RPC endpoint(s)"]
        rpc_ep["evm-rpc pool<br/>eth_getLogs / eth_getBlockByNumber<br/>/ eth_getTransactionReceipt"]
        mem_ep["evm-mempool pool<br/>newPendingTransactions (ws)<br/>+ eth_getTransactionByHash (http)"]
    end
    subgraph sources["source::{rpc,mempool}"]
        rpcsrc["EvmRpcSource<br/>cursor = block# + ordering bit"]
        memsrc["EvmMempoolSource<br/>cursor = arrival counter"]
    end
    subgraph raw["opaque, ChainKind-tagged payload"]
        re["RawEvent<br/>{ chain: 'evm', cursor,<br/>payload: RawPayload::Json(Value) }"]
    end
    subgraph dec["decoder::EvmDecoder"]
        disp["decode()<br/>dispatch: topics -> log, input -> tx"]
        plan["EventPlan / FunctionPlan<br/>(built once, at compile_spec)"]
        toval["to_value()<br/>DynSolValue -> canonical Value"]
    end
    subgraph out["canonical values"]
        de["DecodedEvent<br/>{ kind, name, fields: Value, cursor }"]
    end

    rpc_ep --> rpcsrc --> re
    mem_ep --> memsrc --> re
    re --> disp
    plan -.->|"read-only,<br/>built at write time"| disp
    disp --> toval --> de

decode dispatches purely on the shape of the JSON payload, never on which source produced it: an object carrying topics is a log, one carrying input (and no topics) is a transaction, and nothing ever carries both (decoder/decode.rs). This is the exact mechanism behind Selectors § The source’s account of how both sources feed one decoder. to_value (decode.rs) is the ABI-to-canonical step proper: it walks an alloy_dyn_abi::DynSolValue against the NamePlan built for it at compile time and produces blockwatcher_types::Value (Int/Uint via BigInt/BigUint from the 32-byte big-endian form, Address/Bytes, Str, Array/Map recursively). This crate’s decoder never invents a value shape of its own; it only ever produces the canonical tree every other chain family’s decoder also produces.

The sources

evm-rpc: acquiring confirmed blocks

One file per concern under source/rpc/:

File-by-file map: source/rpc/
FileOwns
config.rsEvmRpcConfig: endpoints, selection (ordered default, or round_robin to spread windows across a tier’s endpoints, weighted by each endpoint’s weight; only a window’s first, unpinned call is affected — the rest stay pinned to whichever endpoint served it), start_block (required, never head-relative; see the field’s own doc comment on why an absolute block replays rather than silently skips a gap), confirmations (default 12), max_lag_blocks (default 3), poll_interval_ms (default 3_000), logs_window (LogsWindow { initial: 512, max: 2048 }), full_block_window (default 8, the ceiling on how wide a range fetched with transaction bodies is ever requested), probe_interval_ms (default 10_000), retry_backoff_max_ms (default 30_000, the ceiling on the doubling retry delay for a window the run loop could not fetch), receipts (ReceiptPolicy, default Always), receipt_concurrency (default 4, how many eth_getTransactionReceipt calls one leaf may have in flight at once), header_batch (default 20, how many eth_getBlockByNumber requests ride one JSON-RPC batch), bloom_screen (default true, whether to skip a window’s eth_getLogs call when every fetched header’s logsBloom proves no monitored address or topic0 can be present) (config.rs).
backoff.rsRetryBackoff, the pacing for retrying a window the run loop could not fetch: the first delay is one poll interval, doubling per consecutive failure up to retry_backoff_max_ms; a provider’s Retry-After (delta-seconds form, surfaced by ScanError::retry_after) raises a delay toward the same ceiling but never past it, and one served window resets the schedule (backoff.rs).
chain.rsRecentChain, a bounded memory (clamp(2 * confirmations, 8, 64) entries) of recently seen (block, hash) pairs, and verify_extends’s two-way failure classification: a window whose own headers don’t chain into each other (an inconsistent endpoint) versus one that chains internally but doesn’t attach to the tracked tip (a genuine reorg) (chain.rs).
scan.rsLogFilter/Window/WindowData/ScanError (scan.rs), then the single-endpoint window fetch and provider-forced-range-split machinery shared by both the live poll loop and the Source::scan dry run (POST /monitors/{id}/test’s fetch mode): fetch_window_on (scan.rs) is the real fetch logic, and fetch_split (scan.rs) is a thin wrapper over fetch_split_boxed (scan.rs), which recursively bisects a window a provider refuses as too wide. fetch_header_only (scan.rs) fetches and parses one block’s header with no eth_getLogs call at all, for a caller that only wants to know a block’s identity.
emit.rsTurns one fetched window into RawEvents: pack_secondary/SECONDARY_KIND_TX/SECONDARY_KIND_LOG pack the cursor’s secondary component so every transaction in a block sorts below every log sharing it (emit.rs); emit_verified_leaf walks a block’s transactions first, then its logs, in that order, for the streaming path, and scan_range does the identical thing for the dry-run path so the two can never disagree on cursor or payload shape (emit.rs).
resume.rsEvmRpcSource::resume, which never trusts a persisted checkpoint’s block outright: re-fetches that block’s header, compares hashes, and on mismatch walks the persisted RecentChain backward against freshly fetched headers. A still-matching ancestor is a proven fork and returns Invalidated { from } so the engine can retract. A walk that matches no tracked ancestor is beyond_window: every tracked entry names a block the live chain denies, so divergence is proven at or below the oldest tracked height and resume returns Invalidated { from } just below that height, a rewind bounded by the tracker’s own depth, never genesis. Each step of that walk, and resume’s own initial checkpoint check, is one probe of fetch_one_header, which costs one eth_getBlockByNumber and no log call (resume.rs).
run.rsEvmRpcSource itself and its Source::run loop: head probing, the confirmation-depth barrier, window-size growth and shrink, and routing a LinkageBreak. A break inside a window never before emitted is retried in place (within_confirmations). A break against already-emitted work returns Invalidated { from } when a tracked ancestor still matches (beyond_confirmations), and when none does (beyond_window) returns Invalidated { from } just below the oldest tracked height, the same bounded rewind resume uses (run.rs).

Whether a poll cycle fetches full transaction bodies at all is itself interest-driven: with no monitor watching any functions selector, the source never asks for them, and the wire shape it produces stays exactly the header-plus-logs profile: the check that gates this is evm_interest(interest).is_some() at emit.rs, read once per cycle from the merged InterestSet the pipeline hands the source.

What a window costs: receipt_concurrency and header_batch

Two knobs govern how many round trips a window spends and how they are paced. Neither changes what a window produces:

  • receipt_concurrency (default 4): how many eth_getTransactionReceipt calls one leaf’s matching transactions may have in flight at once (emit.rs). A receipt is an unpinned consensus read of an already confirmed block, so concurrency moves pacing alone, never which endpoint’s view a window carries; each branch is the same Degraded-retried fetch a serial pass runs, and the per-endpoint rate limiter and circuit breaker still gate every call. A higher value shortens a function-heavy leaf at the cost of burstier provider load.
  • header_batch (default 20): how many eth_getBlockByNumber requests ride one JSON-RPC batch (scan.rs). A window of w blocks costs ceil(w / header_batch) header round trips rather than w of them. A batch is one HTTP request against one endpoint, so it carries the window’s pin and fails the whole window exactly as a lone request does: the batch size changes how many requests a window costs, never how many endpoints it reads. This is also the ceiling past which widening full_block_window stops amortising header calls and only keeps amortising the filter call.

The default value is a wire-shape decision, and it changes the shape this source posts by default: at any value above 1, a window’s headers go out as a JSON-RPC batch array rather than as one request object per block. A provider that rejects batch arrays therefore fails its very first window, loudly: the source publishes Degraded, the run loop retries that window on its doubling backoff schedule, and the pipeline makes no progress until an operator sets header_batch = 1 for that network. That value is the escape hatch. It restores the single-call wire shape byte for byte instead of sending one-member batches, which is what makes it a genuine fallback for a provider that refuses the batch form at all.

This cost model is for a window: a range fetched to emit events from. A resume or deep-reorg probe fetches no events, only a single block’s identity, so it never pays a window’s shape at all: each probe costs one eth_getBlockByNumber and no log call, regardless of header_batch or bloom_screen, via scan.rs’s fetch_header_only. A deep reorg walk runs this probe once per tracked height it checks, so its total cost scales with how far back the walk has to look, never with a window’s width or filter.

Removing the call entirely: bloom_screen

receipt_concurrency and header_batch pace a window’s round trips; bloom_screen (default true) can remove one of them outright. Once a window’s headers are in hand, each header’s logsBloom is checked against the filter’s addresses and topic0 signatures before eth_getLogs is ever called: if every header’s bloom refutes every monitored address, or every monitored topic0, the filter call is skipped and the window is treated as holding no logs, since eth_getLogs requires both to hold and either dimension refuted alone already rules out a match. Against a protocol-conforming node the skip is lossless: its bloom is a superset of its logs, so no log the filter would have matched can hide behind a bloom that already ruled it out. Enabling the screen nonetheless adds a dependency the unscreened path does not carry: correctness now rests on the bloom as well as on the logs response, so a node or caching proxy that serves correct logs behind a zeroed or otherwise-inaccurate bloom loses those logs silently, the same symptom named in Troubleshooting. The setting trades one saved eth_getLogs call for that dependency.

The screen only ever applies where it has something to refute: a header whose bloom cannot be read is never screened regardless of this setting, and a filter with no addresses and no topic0s is never screened either, since there is nothing in it for a bloom to refute. The dry-run scan path behind POST /monitors/{id}/test’s fetch mode never screens, and never collects a header’s bloom at all, so a test fetch reports the same logs a live window would extract from the same block. Disable bloom_screen for endpoints whose blooms are not trusted, where that traded dependency is not worth accepting. Each skip is counted at fetch time, independent of whether the window later survives reorg verification, as blockwatcher_evm_bloom_skips_total; see Observability.

Trusting a header’s bloom to prove absence has one failure mode: an endpoint or caching proxy whose bloom does not actually describe its own block. bloom.rs‘s admits_log closes that gap using an observation a source that trusts its blooms already pays for and needs no extra call to check: a protocol-conforming bloom is a superset of its block’s logs, so scan.rs collects one bloom per header for any window fetched with bloom_screen: true, whether or not the screen itself found anything in that window to refute, and a window that is not screened — an admitting header, a broad filter, or a filter the screen could not parse — still holds both halves of the proof, the logs themselves and the same blocks’ blooms, fetched moments apart. A window whose source does not trust blooms at all (bloom_screen: false) collects none, so it carries no such proof either way; nothing here checks it, and nothing here vouches for it as clean. After eth_getLogs returns on a window that did collect blooms, scan.rs checks every returned log’s own address and topic0 against its own block’s own bloom. A log with no topics is judged on its address alone, since an anonymous event carries no topic0 for a bloom to have admitted in the first place. Because a bloom filter never produces a false negative for an item it actually holds, a bloom that fails to admit a log the same endpoint just returned can only mean that endpoint’s bloom does not describe the block it claims to belong to: there is no way for this check to fire on a healthy endpoint.

The response trades the optimisation away rather than keep trusting a source that has proven its blooms unreliable: the first contradiction disables bloom_screen on the EvmRpcSource instance that observed it, an AtomicBool tripped with a compare-exchange so the accompanying warning logs exactly once regardless of how many leaves of a split carried the contradiction, naming the endpoint, the first contradicting block, and how many logs contradicted. The latch is monotonic within that instance’s own lifetime; nothing re-enables it short of building a new instance.

Building a new instance is exactly what a pipeline restart does, and restarts are routine: the supervisor restarting an exited source, an Invalidated return after a proven reorg, or a monitor change that escalates to a restart all call the evm-rpc module factory again, which calls EvmRpcSource::new and starts the flag at false. The latch does not, and cannot, remember across that rebuild that this endpoint already proved its blooms wrong; a rebuilt instance resumes screening against the same endpoint from a clean slate. A process-wide memory keyed by endpoint name would close that gap, but it is a different design with its own open questions (an endpoint shared across networks would need a shared verdict, and nothing would evict a stale one), so it is not what this latch does. The durable answer available today is the operator’s own setting: once an endpoint has proven its blooms unreliable, set bloom_screen = false for that network so a later restart does not have to relearn the same fact the hard way. Every contradicting log is still counted, latched or not, as blockwatcher_evm_bloom_contradictions_total, labeled pipeline and the endpoint that served the window; see Observability for how to size the damage already done before the trip, using blockwatcher_evm_bloom_skips_total for the same pipeline.

Disable bloom_screen outright for a chain whose nodes are known to emit zeroed or absent blooms in a way that never contradicts a returned log: a header without a readable bloom is never screened regardless of the setting, and never trips the latch, so that case is not this detector’s to catch.

evm-mempool: acquiring pending transactions

The files under source/mempool/, plus the crate’s shared WebSocket wire client (ws.rs, crate-private, used only from here):

File-by-file map: source/mempool/
FileOwns
config.rsEvmMempoolConfig: ws_url_secret, endpoints (for the eth_getTransactionByHash hydration lookups a subscription hash alone cannot avoid), reconnect_ms (default 1_000), idle_policy (IdlePolicyDef { ping_after_ms: 30_000, pong_deadline_ms: 10_000 }, the wire form of ws::IdlePolicy) (config.rs). There is deliberately no start_block and no confirmation depth: a pending stream has no history to begin from and no reorg barrier to honor (config.rs).
pending.rsPendingHashStream/subscribe_pending: the typed layer over ws.rs that names newPendingTransactions and parses each notification down to the hash it carries, nothing more (pending.rs).
run.rsEvmMempoolSource and its Source::run loop: subscribe, then per hash, check whether any monitor on this pipeline watches functions at all before spending a hydration lookup, with no function interest published, the source skips the eth_getTransactionByHash round trip entirely (run.rs), and forward what comes back, tracking a per-run arrival counter as the cursor (run.rs).

The arrival counter resumes from one past whatever the last persisted checkpoint recorded, never from zero on a warm restart:

#![allow(unused)]
fn main() {
let mut arrival = match ctx.checkpoint.as_ref() {
    Some(cp) => cp.cursor.primary.saturating_add(1),
    None => 0,
};
}

(run.rs:204-207). But since nothing pending during a restart gap is ever recovered, that continuation only prevents a within-run number from repeating; it does not, and cannot, make the counter a replayable position across the gap itself. Selectors § The position problem covers exactly what that costs a consumer.

Endpoints, and the env:/url_secret indirection

source::endpoint::EndpointDef (source/endpoint.rs) is the one endpoint shape both sources ask an operator for and that the shared pool builder behind both factories reads: name, url_secret: String, priority (default High), and an optional rate_limit. url_secret never carries a literal URL: a provider’s URL routinely is the credential (an API key in the path or host), so the field holds an env:NAME reference instead, and the reference resolves in two steps, not inside this struct:

  1. Form validation, at config-validate time. EvmRpcConfig::validate (body: source/rpc/config.rs, its SecretRef::parse call over each endpoint’s url_secret at config.rs) and EvmMempoolConfig::validate (body: source/mempool/config.rs, its SecretRef::parse call over ws_url_secret at config.rs, and over each endpoint’s url_secret at config.rs) each reject a string that is not an env:NAME reference. Neither method checks whether the reference actually resolves: that needs the environment, and validation is a pure predicate over configuration.
  2. Resolution, once, at construction. registry.rs’s resolve_endpoint_url/resolve_ws_url call SecretRef::parse(url_secret)?.resolve()?, then check the result parses as a URL with the scheme this source can actually dial (http/https for evm-rpc, ws/wss for evm-mempool): a scheme check, not just a parse, since Url::parse alone would accept an env: reference typo’d into the variable itself just as happily as a real URL (registry.rs). This runs once, before a single EvmEndpoint or Pool is built; no message anywhere in this path ever carries the resolved value.

blockwatcher_types::SecretRef is where the env: prefix itself is parsed and where the environment variable is actually read (crates/blockwatcher-types/src/secret.rs). This crate calls it but does not reimplement it, and blockwatcher-rpc never sees a url_secret or an env: string at all: by the time an EndpointConfig reaches that crate’s Pool, the secret has already been resolved into whichever connection value this crate’s own EvmEndpoint carries.

The decoder

EvmDecoder (decoder/mod.rs) implements every method of Decoder (crates/blockwatcher-ports/src/decoder.rs):

MethodWhat it does here
chainReturns ChainKind::new("evm") (decoder/mod.rs)
compile_specParses spec.payload as alloy_json_abi::JsonAbi; rejects an anonymous event (no topic0 to ever key on), a selector collision between two declarations, and an ABI with zero events and zero functions; builds SpecInner { events: HashMap<B256, EventPlan>, functions: HashMap<[u8;4], FunctionPlan> } (decoder/mod.rs)
compileDelegates to selector::compile (below) (decoder/mod.rs)
decodeDelegates to decode::decode (decoder/mod.rs)
interestDelegates to selector::interest (decoder/mod.rs)
merge_interestUnions addresses/signatures like the port’s own default, but additionally: blanks the merged address set the moment any input selector watches every address (an empty addresses means “any address,” and a plain union would narrow a source’s fetch below what one monitor asked for), and unions every input’s EvmInterest.function_selectors into the merged chain_specific (decoder/mod.rs)

Files behind compile_spec split write-time work from the per-event/per-function hot-path plan it produces:

  • abi_types.rs: pure naming and typing rules shared by the schema side and the plan side, so a dotted or positional field name can never drift between what a predicate compiles against and what decode actually emits: positional_name/component_name decide field-name spelling, value_type maps one ABI type string (recursing through array dimensions) to blockwatcher_types::ValueType, collapsing any tuple to ValueType::Map (abi_types.rs).
  • compile.rs: ABI fragment to EventSchema, run once per declared event or function: event_schema/function_schema flatten every parameter, rejecting a duplicate parameter name and a duplicate tuple component name at every nesting depth (compile.rs). This is also where the tx/block/log namespaces every compiled EVM spec carries come from: namespaces() (compile.rs) declares the union of what the log path and the function-call path can each fill in, which is the source Selectors § What each kind decodes documents from the predicate-author’s side.
  • plan.rs: the decode-hot-path plan itself, precomputed once per event or function at spec-compile time and never rebuilt: build/build_function resolve every ABI type string into an alloy_dyn_abi DynSolEvent/DynSolCall up front, and pair each top-level parameter with the exact slot decode will read it from (Source::Indexed(n) or Source::Body(n)) and a NamePlan describing how to reassemble its container shape (plan.rs). decode.rs walks this plan and never re-parses a type string on the hot path: that is the entire reason this file exists apart from compile.rs.

decode.rs (decoder/decode.rs) is the hot path proper: Envelope/TxEnvelope parse a log’s or a transaction’s fixed field set once per call (decode.rs), the dispatch described in From an RPC endpoint to a canonical value above routes to decode_log or decode_call, and to_value performs the actual ABI-value-to-canonical-Value conversion against the precomputed plan. DecodeOutcome (the port’s own type) distinguishes two different non-events precisely: an envelope or ABI-encoded payload this decoder cannot make sense of at all is undecodable (counted, surfaced to operators); a well-formed occurrence whose address/topic0 or four-byte selector no compiled entry named is no_match, not a failure, since most logs on a filtered feed and most transactions in a block fall outside any one monitor (decode.rs).

Selector implementation

Selectors documents the compile-time presence rule (events/functions/addresses, and what “naming neither” means) and what each kind hands a predicate in full; this section is only the structural shape underneath it. The dispatch table selector::compile (decoder/selector.rs) builds against is, exactly (decoder/selector.rs:13-25):

#![allow(unused)]
fn main() {
/// One selector entry, self-contained: its `addresses` restrict only the
/// events and functions it names, never a sibling entry's.
pub(crate) struct Entry {
    /// `None` means any address.
    pub addresses: Option<BTreeSet<[u8; 20]>>,
    pub events: HashMap<B256, EventPlan>,
    pub functions: HashMap<[u8; 4], FunctionPlan>,
}

/// Every entry a monitor's selectors compiled to. Entries are OR'd.
pub(crate) struct SelectorInner {
    pub entries: Vec<Entry>,
}
}

events is keyed by the topic0 a log’s first topic carries; functions is keyed by the 4-byte selector a transaction’s calldata leads with. Each Entry is self-contained (its own addresses restrict only its own two tables, never a sibling entry’s), and SelectorInner.entries is the full OR across every selector a monitor names. selector::interest (decoder/selector.rs) derives the fetch-narrowing InterestSet from this same structure: an entry with no addresses (any address) blanks the merged address hint for the whole selector, which is why EvmDecoder::merge_interest above has to repeat that same rule one level up, across selectors rather than within one. Both Entry and SelectorInner live behind CompiledSelector’s type erasure; nothing outside this crate ever names them directly.

evm-mempool compiles selectors exactly the same way evm-rpc does (selection is checked against the spec, not against the source that will feed it), but structurally can only ever populate the functions half of any entry’s dispatch table, since this source never produces a payload decode would route down the log path at all. An events-only selector on an evm-mempool network therefore compiles cleanly and simply never fires; Selectors § Comparison table is the definitive statement of that outcome per selector kind and source.

The registry

Three ModuleRegistry implementations, one per module this crate ships, each declaring its own const NAME and factory() next to its own code (registry.rs):

RegistryNAMEFactory builds
EvmRpcRegistry"evm-rpc"Deserializes EvmRpcConfig, validates it, resolves every endpoint into a Pool<EvmEndpoint>, and constructs EvmRpcSource::new(pool, config) (registry.rs)
EvmMempoolRegistry"evm-mempool"Deserializes EvmMempoolConfig, validates it, resolves the subscription URL and the hydration endpoints, and constructs EvmMempoolSource::new(pool, ws_url, config) (registry.rs)
EvmDecoderRegistry"evm"Deserializes an empty, deny_unknown_fields config and returns Arc::new(EvmDecoder) (registry.rs)

sources::get_all() and decoders::get_all() (registry.rs, both re-exported at the crate root as sources/decoders) fold the three into the two lookup tables a composition root’s catalog needs. blockwatcher-embed‘s build_catalog (crates/blockwatcher-embed/src/catalog.rs) consumes both: it loops each enumeration and calls catalog.register_source/register_decoder, which rejects a duplicate name: the same family!-macro-generated contract (crates/blockwatcher-core/src/catalog.rs) every other module family goes through. build_pool (registry.rs) is the one function both EvmRpcRegistry and EvmMempoolRegistry funnel through, so the two sources’ retry, rate-limit, and timeout behavior can never independently drift: the same “shared builder” pattern blockwatcher-core’s own catalog.rs doc comment describes for module registration in general.

Neighbours

blockwatcher-evm depends on, in production:

  • blockwatcher-types: vocabulary crate (RawEvent, Value, ChainKind, SecretRef, and every id type)
  • blockwatcher-ports: the Source/Decoder port traits this crate implements
  • blockwatcher-rpc: the endpoint pool both sources parameterize with EvmEndpoint
  • async-trait: required to implement the async fn-bearing Source trait
  • alloy-primitives: Address/B256/hex helpers
  • alloy-json-abi: parsing a spec’s Solidity JSON ABI
  • alloy-dyn-abi: resolving ABI type strings and decoding calldata/log data against them
  • num-bigint: the arbitrary-precision integers a canonical Value::Int/Uint holds
  • reqwest: the HTTP client behind every EvmEndpoint
  • url: parsing and validating a resolved endpoint URL’s scheme
  • serde: (de)serialization derive for every config
  • serde_json: the raw JSON payload shape both sources emit and the decoder consumes
  • thiserror: the derive behind EvmRpcError
  • tokio (sync/time/macros/rt features): the async runtime both sources’ run loops execute on
  • tokio-util: cancellation plumbing shared with the rest of the workspace
  • tokio-tungstenite: the WebSocket transport ws.rs wraps
  • futures: stream/sink combinators ws.rs uses
  • tracing: this family’s structured logging
  • metrics: the blockwatcher_evm_mempool_skips_total-style counters this family emits

and, in [dev-dependencies] only:

  • blockwatcher-evm-testkit: a scripted mock JSON-RPC/WebSocket node (MockNode, MockWsNode) and a SimChain fixture builder this crate’s own tests drive; the dependency cycle back to blockwatcher-evm is a dev-only edge cargo permits
  • blockwatcher-ports (fakes feature): fakes for cross-checking against the port contract
  • blockwatcher-testkit: shared test scaffolding

The following crates depend on it directly (per the dependency table):

  • blockwatcher-evm-testkit: reaches back into this crate for real types its mock node fixtures build against (a dev-only cycle, never a production edge)
  • blockwatcher-embed: the composition façade registers both sources and the decoder into the engine’s module catalog; the blockwatcher binary reaches this crate only through embed

Reading the source

  1. Start at lib.rs: four pub mod declarations (decoder, jsonrpc, registry, source), two private ones (interest, ws), and the crate-root re-exports (EvmInterest, sources, decoders).
  2. source/endpoint.rs: the config-plane vocabulary shared by both sources, and its doc comment on why url_secret resolves once at construction rather than per call, unlike the webhook sink’s own per-delivery resolution.
  3. jsonrpc.rs: EvmEndpoint, EvmRpcError, and its Classify impl’s documented mapping table; read this before either source, since both build on it.
  4. source/rpc/config.rs, then chain.rs, scan.rs, emit.rs, resume.rs, run.rs in that order: each file’s own doc comment states what it owns and why it sits apart from its neighbours.
  5. source/mempool/config.rs, then pending.rs, run.rs: read run.rs’s module doc comment first; it states every contract difference from evm-rpc in one place.
  6. decoder/abi_types.rs, then compile.rs, plan.rs: the three write-time files, in the order the module doc comments cross-reference each other.
  7. decoder/selector.rs, then decoder/decode.rs: compile-time selector dispatch, then the hot path that reads it.
  8. decoder/mod.rs: EvmDecoder’s full Decoder impl, last, once every piece it delegates to is familiar.
  9. registry.rs: the three ModuleRegistry impls, build_pool, and resolve_endpoint_url/resolve_ws_url; its own module doc comment states the registration convention every module family in the workspace follows.

blockwatcher-rpc

blockwatcher-rpc is a resilient pool of named remote endpoints, chain- and transport-agnostic: the consumer supplies each operation (any async request/response call over its own connection handle) and its error classification, and the pool supplies everything around the request: endpoint selection by priority, per-endpoint circuit breakers, preemptive rate limiting, a whole-call deadline, per-attempt timeouts, retry by error class, one concurrency permit held across retries, health probing, and endpoint-labeled metrics, per the crate doc comment (crates/blockwatcher-rpc/src/lib.rs). It is a module crate: blockwatcher-evm’s evm-rpc and evm-mempool sources are consumers, for example, but nothing about its own dependencies or its public surface names EVM, HTTP, or any other chain or transport at all.

This page covers the pool’s own mechanics (selection, the breaker, the limiter, retry and backoff, health probing) and the one seam that makes “chain- and transport-agnostic” true structurally rather than just by convention. Observability § Other metrics on this endpoint already documents every metric this crate exports from an operator’s point of view; this page does not restate that table.

Key takeaways

  • blockwatcher-rpc is a resilient pool of named remote endpoints, chain- and transport-agnostic: the consumer supplies the operation and its error classification, and the pool supplies selection, breaking, rate limiting, retry, and health probing.
  • Nothing about its own dependencies or public surface names EVM, HTTP, or any other chain or transport; blockwatcher-evm’s sources are consumers, not a special case.
  • This page covers the pool’s own mechanics and the seam that makes it chain- and transport-agnostic structurally, not the metrics it exports, which observability already documents.

Responsibilities

  • Run one caller-supplied async operation against a pool of named endpoints to completion: pick an endpoint, bound the attempt with a timeout, retry by the failure’s ErrorClass, and return either a served value or a typed exhaustion reason: Pool::execute (crates/blockwatcher-rpc/src/pool.rs).
  • Select an endpoint by priority tier (High before Low; within a tier, config order under the default Ordered strategy, or a weighted rotation under RoundRobin), skipping one whose breaker does not currently admit or whose rate limiter has no token available for this call: Pool::select/Pool::select_pinned (pool.rs).
  • Trip a per-endpoint circuit breaker, Breaker, after a configurable run of consecutive classified failures, admit one half-open trial after a cooldown, and reset it on any success (breaker.rs).
  • Enforce a preemptive per-endpoint rate limit (TokenBucket, whose burst capacity equals its configured rate) before a call ever leaves the pool (limiter.rs).
  • Retry a Transient failure or an attempt timeout on a doubling backoff; retry a RateLimited failure on the same schedule without touching the breaker; return a Permanent or RetryNarrower failure immediately, with its retry budget unburned, all inside Pool::execute (pool.rs).
  • Sweep every endpoint whose breaker is not strictly open, concurrently, under the pool’s attempt timeout, without ever feeding a probe’s result back into the breaker itself: Pool::probe_health (pool.rs).
  • Publish blockwatcher_rpc_* metrics through six *_TOTAL constants for every attempt, failure, breaker transition, rate-limit wait, exhaustion, and probe failure, each labeled endpoint (pool.rs).

Not this crate’s job: knowing what a request means, what wire format it takes, or what chain it targets: the consumer’s own connection type C and async closure carry all of that, and Pool<C> never inspects either, per the crate doc comment (lib.rs); resolving an env:NAME secret reference into a URL: that lives in blockwatcher-types::SecretRef, called once by whichever module crate constructs a pool (see The env:/url_secret indirection below); retrying a sink delivery or deciding when a match becomes a dead letter: that is blockwatcher-core’s deliver_with_retry (crates/blockwatcher-core/src/pipeline/delivery.rs), a wholly separate retry loop for a wholly separate kind of call; caching a response: what is safe to cache and for how long is a property of the consumer’s domain, not of transport resilience, so this crate does not attempt it at all, per the crate doc comment (lib.rs).

Key types and traits

NameKindRole
Pool<C>structThe endpoint pool itself, generic over one connection type C shared by every endpoint (pool.rs)
Pool::executeasync fnRuns one caller-supplied operation against the pool: selection, breaker, limiter, retry, deadline (pool.rs)
Pool::probe_healthasync fnConcurrent liveness sweep over every non-strictly-open endpoint (pool.rs)
PoolConfigstructPool-wide tuning: max_concurrency, attempt_timeout, backoff_initial/backoff_max, breaker_threshold, breaker_cooldown, strategy (pool.rs)
EndpointConfigstructOne endpoint’s name, Priority, optional RateLimit, and rotation weight (NonZeroU32 — zero is unwritable — capped at MAX_WEIGHT, since the ring materialises one slot per weight unit): no URL, no secret, no transport detail (pool.rs)
SelectionStrategyenumHow a tier’s endpoints are ordered when no pin dictates the choice: Ordered (default, first admissible in config order) or RoundRobin (weighted rotation) (pool.rs)
ExecuteOptionsstructPer-call overrides: the whole-call deadline, an optional endpoint pin, and an exclude set (pool.rs)
Served<T>structA value tagged with the endpoint name that served it (pool.rs)
PriorityenumHigh/Low selection tier; Low is only consulted when zero High endpoints are admissible (pool.rs)
RateLimitstruct{ rps: NonZeroU32 }, the wire shape an EndpointDef’s own rate_limit resolves into (pool.rs)
PoolError<E>enumexecute’s failure type: NoEndpoints, PinUnavailable, DeadlineExceeded, Exhausted, Permanent, Narrower (error.rs)
ProbeFailure<E>enumWhy one endpoint’s probe produced nothing: TimedOut or Failed(E) (pool.rs)
PoolSetupErrorenumWhy Pool::new refused construction: NoEndpoints, DuplicateName, or WeightTooLarge (pool.rs)
Attempt<E>, AttemptError<E>struct/enumOne failed attempt’s endpoint and cause, threaded through PoolError::DeadlineExceeded/Exhausted (error.rs)
Classify, ErrorClass (re-exports)trait/enumRe-exported from blockwatcher_ports (lib.rs), not defined here; the one contract a consumer’s error type must satisfy: fn class(&self) -> ErrorClass

The seam: generic over a connection, not a trait

There is no trait Endpoint or trait Transport anywhere in this crate: Pool<C> is generic over a bare connection type C with no bound on C at all (impl<C> Pool<C>, pool.rs). The only contract a consumer must satisfy sits on the error type Pool::execute’s operation returns: E: Classify + std::error::Error (pool.rs), where Classify is declared in blockwatcher_ports (crates/blockwatcher-ports/src/error.rs) and merely re-exported here. That single method, fn class(&self) -> ErrorClass (one of Transient/Permanent/RateLimited/RetryNarrower, blockwatcher-ports/src/error.rs), is everything the pool needs to decide whether an attempt’s failure feeds the breaker, retries on backoff, or returns immediately.

blockwatcher-evm is the concrete instance: it parameterizes Pool<EvmEndpoint>, where EvmEndpoint { client: reqwest::Client, url: url::Url } (crates/blockwatcher-evm/src/jsonrpc.rs) is its own HTTP connection handle, and implements Classify for EvmRpcError directly on its own error enum (jsonrpc.rs), mapping HTTP status codes and JSON-RPC error bodies onto the four classes. blockwatcher-rpc never sees reqwest, url::Url, or anything JSON-RPC-shaped: it only ever calls op(&endpoint.conn) and reads back a Result<T, E> it did not construct.

flowchart LR
    subgraph consumer["blockwatcher-evm (a consumer)"]
        ep["EvmEndpoint<br/>{ client, url }"]
        err["EvmRpcError<br/>impl Classify"]
        op["closure: |ep| jsonrpc::call(ep, ...)"]
    end
    subgraph rpcpool["blockwatcher-rpc::Pool&lt;C&gt;"]
        sel["select()<br/>priority + breaker + limiter"]
        exec["execute()<br/>timeout, retry, backoff"]
        brk[("Breaker<br/>per endpoint")]
        lim[("TokenBucket<br/>per endpoint")]
    end
    ep -->|"C = EvmEndpoint"| rpcpool
    op -->|"op: Fn(&amp;C) -> Fut&lt;Result&lt;T, E&gt;&gt;"| exec
    exec --> sel
    sel --> brk
    sel --> lim
    exec -->|"E::class()"| err
    exec -->|"metrics::counter!<br/>blockwatcher_rpc_*"| metrics[("process-global<br/>metrics::Recorder")]

Selection: priority, breaker, limiter, strategy

Pool::select (pool.rs) walks Priority::High endpoints first, then Priority::Low. Within a tier, the walk follows the tier’s rotation ring — endpoint indices in configuration order, each repeated its EndpointConfig::weight — and where it starts is the PoolConfig::strategy choice: Ordered (the default) always starts at the ring’s head, so the first admissible endpoint in configuration order wins every call and config order stays meaningful as the operator’s preference order; RoundRobin starts one position later per selection, spreading consecutive calls across the tier’s admissible endpoints in proportion to their weights. Rotation is best-effort, not strict fairness: the cursor advances once per selection whether or not the endpoint it landed on was admissible, so a broken endpoint’s slot redirects to its successor. Weights are inert under Ordered. Within a tier, an excluded endpoint or one whose breaker does not admit is skipped outright; among the rest, an endpoint whose rate limiter has no token available right now is skipped for this call rather than waited on, so one endpoint’s replenish interval never stalls a call another endpoint could serve immediately. Low is consulted only when zero High endpoints are admissible: a High endpoint that is merely rate-limited still counts as admissible, because being over quota is not ill health, and spilling to Low on quota would leak load past the operator’s priority intent (pool.rs, doc comment). ExecuteOptions::pin bypasses this walk entirely and asks for one named endpoint by name; the pool never substitutes another endpoint behind a pin: a pin that cannot serve fails as PinUnavailable, naming why (pool.rs).

Circuit breaker

stateDiagram-v2
    [*] --> Closed
    Closed --> Closed: success<br/>(resets streak)
    Closed --> Open: threshold consecutive<br/>classified failures
    Open --> HalfOpen: cooldown elapses
    HalfOpen --> Closed: trial succeeds
    HalfOpen --> Open: trial fails<br/>(re-opens for a full cooldown)

Breaker (breaker.rs, crate-private) tracks consecutive_failures and opened_at. admits is true when not strictly open; is_open is true only within cooldown of the failure that tripped it. record_failure returns whether this call is what transitioned the breaker from admitting to strictly open (breaker.rs); a metric counting “breaker opened” events must use that return value, not every failure, since the same streak keeps failing past the threshold without re-tripping, and a failed half-open trial is itself a fresh open transition. Only an attempt timeout or a Transient-classified failure ever calls record_failure; RateLimited explicitly does not, since quota exhaustion is not endpoint ill health (pool.rs).

Rate limiter

TokenBucket (limiter.rs, crate-private) is a per-endpoint token bucket whose burst capacity equals its configured rps: a fresh bucket can serve rps calls immediately, then refills continuously (not in fixed windows) at that same rate. next_available reports the exact wait until one token exists, which is what lets the pool, when every candidate in a tier is rate-limited, sleep exactly the shortest wait across candidates rather than a heuristic (pool.rs).

Retry, backoff, and the deadline

execute acquires one Semaphore permit up front and holds it across every retry; the whole-call deadline bounds even the wait for that permit (pool.rs). Each attempt then runs under min(attempt_timeout, time remaining before deadline) (pool.rs). On the result:

  • Permanent and RetryNarrower return immediately, retries unburned, since the pool cannot repair either by trying again (pool.rs).
  • An attempt timeout or a Transient failure records a breaker failure and retries after a backoff that doubles from backoff_initial up to backoff_max (pool.rs).
  • A RateLimited failure retries on the same doubling schedule but never touches the breaker (pool.rs).

This is retry over one RPC call, distinct from blockwatcher-core’s deliver_with_retry for sink deliveries (Delivery guarantees), and the two retry loops share no code and answer different questions: this one asks “should the pool try another attempt against an endpoint,” that one asks “should the engine try another delivery to a sink, or dead-letter the match.”

Health probing

Pool::probe_health (pool.rs) runs a caller-supplied probe concurrently against every endpoint whose breaker is not strictly open: an open endpoint is omitted from the result entirely (“recovering, no data”), not represented as a failure. Results never feed the breakers back: passive health comes from real traffic through execute, and probing is observation layered on top of it, never a second input into it (pool.rs). A sweep bypasses both the concurrency permit and every endpoint’s rate limiter on purpose, so liveness probing is never starved by the same quota it exists to observe (pool.rs).

The env:/url_secret indirection, and where it actually lives

Nothing in this crate parses an env: reference or a url_secret field: EndpointConfig carries only a name, a Priority, and an optional RateLimit (pool.rs); it has no URL field at all. The indirection an operator writes as "url_secret": "env:SEPOLIA_RPC_URL" resolves in two other places, neither of them blockwatcher-rpc:

  • The reference format itself (SecretRef::parse/SecretRef::resolve) is defined once, in the vocabulary crate, crates/blockwatcher-types/src/secret.rs. resolve is the only I/O that crate performs, and it never caches the result: env: is read fresh at each call, though for an environment variable that changes nothing an operator can observe, since a process’s environment is fixed at spawn.
  • The call site that actually invokes it, for evm-rpc’s endpoint pool, is resolve_endpoint_url in crates/blockwatcher-evm/src/registry.rs (documented in full on blockwatcher-evm), run once, at source-construction time, before a single EvmEndpoint or Pool is built. By the time an EndpointConfig reaches this crate, the secret has already been resolved into whatever connection value C carries; the pool itself never holds, logs, or has any way to name a secret reference at all.

Neighbours

blockwatcher-rpc depends on, in production:

  • blockwatcher-ports: the trait boundary; only Classify/ErrorClass are actually used, re-exported at the crate root
  • thiserror: the derive behind PoolError/AttemptError/PoolSetupError/ProbeFailure
  • tokio (sync/time/macros/rt features): the semaphore, timers, and async runtime every retry and probe loop runs on
  • metrics: the facade the pool’s six named constants emit through
  • tracing: this crate’s own structured logging

and, in [dev-dependencies] only:

  • blockwatcher-testkit: RecordingMetrics, used to assert on emitted counter names and labels instead of a real Prometheus backend
  • tokio (rt-multi-thread/test-util features): multi-threaded and paused-time async tests

The following crates depend on it directly (per the dependency table):

  • blockwatcher-evm: parameterizes Pool<EvmEndpoint> for both evm-rpc and evm-mempool, and implements Classify on its own EvmRpcError
  • blockwatcher-evm-testkit: a direct production dependency (crates/blockwatcher-evm-testkit/Cargo.toml), not only a transitive one through blockwatcher-evm: single_endpoint_pool (blockwatcher-evm-testkit/src/pool.rs) builds a real Pool<EvmEndpoint> directly, so every EVM source test that points a module at a mock node goes through this crate’s own types

Reading the source

  1. Start at lib.rs: the crate doc comment states the whole contract in one paragraph, and the pub use lines are the entire public surface, drawn from error.rs, blockwatcher_ports, and pool.rs.
  2. error.rs: Attempt/AttemptError/PoolError, each generic over the consumer’s own error type E: std::error::Error.
  3. breaker.rs: Breaker, its methods, and the doc comment’s closed/open/half-open state machine; read this before pool.rs, the only module that calls it.
  4. limiter.rs: TokenBucket, try_take/next_available; read this before pool.rs’s select/select_pinned, its only callers.
  5. pool.rs: Pool<C>, select/select_pinned, execute, probe_health, in that order; the *_TOTAL metric constants sit near the top of the file, each documented with the exact condition that increments it.

blockwatcher-storage

blockwatcher-storage implements the Storage port twice over: versioned, optimistic-concurrency resources, per-network checkpoints, an append-only dead-letter queue, persisted operator pause, a bounded delivery journal, and the gate hit journal (gate_hits / gate_meta), backed by an in-memory map or a durable sqlite file (crates/blockwatcher-storage/src/lib.rs). It is a module crate: the heavy driver (rusqlite) is quarantined here, and nothing above the Storage port ever has to know which backend a running instance chose.

Delivery guarantees § Dead letters already states the operator-facing consequence of the choice between backends: sqlite is what makes a dead-letter queue survive a restart at all, memory does not. This page covers the mechanics underneath that: the Storage contract in full, each backend’s concrete data structures, sqlite’s schema and migration approach, and exactly how optimistic concurrency and dead-letter payload absence are implemented: verified against the schema and the SQL itself, not restated at the operator level.

Key takeaways

  • blockwatcher-storage implements the Storage port twice over: an in-memory backend with no persistence, and a durable sqlite-backed one.
  • The heavy driver (rusqlite) is quarantined in this crate; nothing above the Storage port ever has to know which backend a running instance chose.
  • sqlite is what makes a dead-letter queue survive a restart at all; memory does not.

Responsibilities

  • Implement the full Storage port over an in-memory HashMap-backed structure with no persistence at all: MemoryStorage (memory.rs), including the delivery journal as a per-network Vec.
  • Implement the full Storage port over one sqlite file and one guarded connection, durable across a restart: SqliteStorage (sqlite.rs).
  • Enforce optimistic concurrency on every resource write and delete: a mismatched expected version is a typed VersionConflict, never a silent overwrite, in both backends (memory.rs, sqlite.rs).
  • Apply put_batch’s create-only, all-or-nothing contract atomically in both backends, overriding the port’s own non-atomic default loop (memory.rs, sqlite.rs).
  • Register both backends under the module names "memory" and "sqlite" so a composition root’s catalog can select either by config (registry.rs).
  • (sqlite only) Create its schema idempotently on first open, migrate schema version 1 → 2 (delivery journal table), 2 → 3 (persisted pause), and 3 → 4 (gate_hits / gate_meta) in place, refuse to open a file written by a newer schema version, and serialize every connection access through one mutex inside spawn_blocking so the async runtime never blocks on file I/O: open_blocking (sqlite.rs).

Not this crate’s job: declaring the Storage trait itself, or any of the types it moves (Checkpoint, DeadLetter, VersionedRecord, ResourceKind): those are blockwatcher-ports and blockwatcher-types; deciding when a checkpoint is safe to persist, or retrying a failed persist: blockwatcher-core’s CheckpointWriter owns that policy and calls this crate’s backends only to execute the write it already decided to make; deciding that a dead letter should be discarded outright, singly or in bulk, rather than replayed: that is ControlHandle::discard_dead_letter/ discard_dead_letters in blockwatcher-core, which this crate’s delete_dead_letter/delete_dead_letters only execute; pruning the delivery journal: backends self-prune behind journal_depth on each record_delivery, and there is no operator prune API; picking which backend a deployment runs: that is blockwatcher.toml, read by blockwatcher-core’s module catalog.

Key types and traits

NameKindRole
MemoryStoragestructIn-process Storage implementation; nothing survives the process (memory.rs)
memory::RegistrystructModuleRegistry impl registering the "memory" storage module, taking no configuration (memory.rs)
SqliteStoragestructSingle-file, single-connection durable Storage implementation (sqlite.rs)
sqlite::RegistrystructModuleRegistry impl registering the "sqlite" storage module: { path, busy_timeout_ms } (sqlite.rs)
storages::get_allfnFamily enumeration folding both backends into a factory lookup table (registry.rs)

Neither backend defines its own error type: both return blockwatcher_ports::StorageError (VersionConflict, AlreadyExists, NotFound, Backend { message, class }, InvalidConfig), and neither backend’s Config struct is pub (reachable only through its ModuleRegistry::factory), documented via its own registry_examples/*.json file rather than a public type.

The Storage port contract

Every method below is declared once, in crates/blockwatcher-ports/src/storage.rs, and both backends implement every one of them:

MethodCategoryPurpose
get, listresourcesFetch one resource by (kind, id), or every resource of a kind, unpaginated (storage.rs)
put, deleteresourcesCreate or optimistic-concurrency update, and version-guarded delete (storage.rs)
put_batchresourcesCreate-only batch write; the trait’s own default is a plain, non-atomic loop over put that a backend may override (storage.rs)
get_checkpoint, put_checkpointcheckpointsRead and write one network’s resume point (storage.rs)
list_checkpoints, delete_checkpointcheckpointsEvery persisted checkpoint, unpaginated; remove one explicitly (storage.rs)
record_dead_letterdead lettersAppend a match that exhausted delivery; prune the oldest letters behind retention in the same write, returning how many it dropped (storage.rs)
list_dead_lettersdead lettersPage a network’s dead letters in arrival order (storage.rs)
count_dead_lettersdead lettersHow many dead letters a network currently has, without paging through them; the trait’s own default is list_dead_letters(..).len() (storage.rs)
delete_dead_letter, delete_dead_lettersdead lettersDiscard one letter by match id, or every letter matching optional sink/monitor filters, without attempting delivery (storage.rs)
update_dead_letterdead lettersReplace a letter in place at the same queue position, after a failed replay (storage.rs)
set_paused, list_pausedpausePersist or clear one monitor’s or network’s pause by id, and list every id currently paused for a target (storage.rs)
record_deliverydelivery journalRecord that match_id was delivered or dead-lettered at cursor; prune rows behind journal_depth in the same write (storage.rs)
list_deliveries_afterdelivery journalDeliveries whose cursor is strictly after from, in cursor order; unknown pipeline is empty (storage.rs)
forget_deliverydelivery journalDrop a journaled id after a successful retract; absent ids succeed (storage.rs)
get_gate_stategate journalFetch the full gate state (hits and meta) for one (pipeline, monitor) in one read (storage.rs)
replace_gate_hitsgate journalReplace the entire hit journal for one (pipeline, monitor) (storage.rs)
put_gate_metagate journalUpsert the metadata sidecar for one (pipeline, monitor) (storage.rs)
prune_gate_hits_aftergate journalDelete hold rows with cursor > from for a pipeline; rows with cursor ≤ from stay (storage.rs)
delete_gate_stategate journalDrop that monitor’s gate_hits and gate_meta (gate-envelope change / monitor delete, always with the pipeline stopped) (storage.rs)

Every method on the trait belongs to one of these categories: resources (keyed by ResourceKind × id, with a u64 version for optimistic concurrency), checkpoints (keyed by NetworkId), dead letters (keyed by NetworkId + MatchId), pause (keyed by PauseTarget + id), the delivery journal (keyed by NetworkId + MatchId, ordered by cursor), and the gate journal (gate_hits / gate_meta, keyed by pipeline + monitor). Memory, sqlite, and FlakyStorage all implement it; exercise_storage_contract covers the gate methods too.

Both backends persist the identical categories; only where they land differs:

flowchart LR
    subgraph categories["categories"]
        res["resources<br/>kind + id keyed, versioned"]
        chk["checkpoints<br/>one cursor per network"]
        dl["dead letters<br/>append-only queue per network"]
        pause["pause<br/>target + id keyed"]
        journal["delivery journal<br/>match ids in a cursor window"]
    end
    categories --> mem["MemoryStorage<br/>HashMap, gone on restart"]
    categories --> sql["SqliteStorage<br/>one file, survives restart"]

The memory backend

MemoryStorage holds one Mutex<State> (memory.rs), where State is one plain Rust collection per category and nothing else (memory.rs):

#![allow(unused)]
fn main() {
#[derive(Default)]
struct State {
    records: HashMap<(ResourceKind, String), (u64, serde_json::Value)>,
    checkpoints: HashMap<NetworkId, Checkpoint>,
    /// Appended to, never reordered: operators triage dead letters in the
    /// order delivery gave up on them.
    dead_letters: HashMap<NetworkId, Vec<DeadLetter>>,
    /// Per-pipeline delivered match ids, newest write prunes by primary.
    journal: HashMap<NetworkId, Vec<(Cursor, MatchId)>>,
    /// Present means paused; a resume removes the entry.
    paused: BTreeSet<(PauseTarget, String)>,
}
}

Every method is a lock, a map operation, and an unlock, with no I/O anywhere in the file (module doc, memory.rs). Optimistic concurrency is a plain match over (expected_version, current) in put (memory.rs); put_batch pre-validates every entry against both existing state and the rest of the batch before writing any of it, under one lock acquisition, which makes it genuinely atomic here despite the trait’s own default being a non-atomic loop (memory.rs). Dead letters are a real, ordered Vec<DeadLetter> per network (append-only except for delete_dead_letter/delete_dead_letters and update_dead_letter’s targeted mutations, memory.rs), not a stub: paging (skip/take), removal, and in-place replacement all behave exactly as the port contract specifies. Nothing here is written to disk; a process restart loses every record, checkpoint, dead letter, and pause, matching the crate’s own trade-off note (memory.rs).

The sqlite backend

Schema

One CREATE TABLE/CREATE INDEX batch per schema version (SCHEMA_V1, SCHEMA_V2, SCHEMA_V3, SCHEMA_V4 in sqlite.rs), with SCHEMA_VERSION 4:

CREATE TABLE resources ( … );
CREATE TABLE checkpoints ( … );
CREATE TABLE dead_letters ( … );
CREATE INDEX dead_letters_pipeline ON dead_letters (pipeline, seq);

CREATE TABLE delivery_journal (
  pipeline         TEXT NOT NULL,
  cursor_primary   INTEGER NOT NULL,
  cursor_secondary INTEGER NOT NULL,
  match_id         TEXT NOT NULL,
  PRIMARY KEY (pipeline, match_id)
);
CREATE INDEX delivery_journal_pipeline_primary
  ON delivery_journal (pipeline, cursor_primary);

CREATE TABLE pauses (
  target TEXT NOT NULL,
  id     TEXT NOT NULL,
  PRIMARY KEY (target, id)
);

CREATE TABLE gate_hits (
  pipeline         TEXT NOT NULL,
  monitor          TEXT NOT NULL,
  cursor_primary   INTEGER NOT NULL,
  cursor_secondary INTEGER NOT NULL,
  event_index      INTEGER NOT NULL,
  event_ts         INTEGER NOT NULL,
  event_json       TEXT NOT NULL,
  PRIMARY KEY (pipeline, monitor, cursor_primary, cursor_secondary, event_index)
);
CREATE INDEX gate_hits_pipeline_primary ON gate_hits (pipeline, cursor_primary);
CREATE TABLE gate_meta (
  pipeline      TEXT NOT NULL,
  monitor       TEXT NOT NULL,
  last_emit_ts  INTEGER,
  aux           BLOB,
  PRIMARY KEY (pipeline, monitor)
);

Each table stores its whole record as one JSON TEXT blob (value, checkpoint, entry) alongside only the columns a query needs to filter or order by, pauses excepted: a pause carries no payload beyond its own existence, so target and id are the entire row and presence alone means paused. resources keys on (kind, id); checkpoints keys on pipeline; pauses keys on (target, id); dead_letters gets an autoincrementing seq that is both its primary key and, via the dead_letters_pipeline index, the ordering list_dead_letters pages by: arrival order, for free, from the column sqlite already maintains.

Migration approach

A single idempotent function run once at open (open_blocking, sqlite.rs): MIGRATIONS is an ordered array holding each version’s schema batch ([SCHEMA_V1, SCHEMA_V2, SCHEMA_V3, SCHEMA_V4]), and SCHEMA_VERSION is simply its length, so adding a version and bumping the version number can never drift apart from each other. Opening reads PRAGMA user_version and slices MIGRATIONS from that index onward: a fresh file (0) runs every step, a 1 file runs only SCHEMA_V2 onward, a 2 file runs only SCHEMA_V3 onward, a 3 file runs only SCHEMA_V4, and a file already at SCHEMA_VERSION runs nothing. Every pending step executes inside one transaction, PRAGMA user_version is set to SCHEMA_VERSION inside that same transaction, and the whole thing commits together, so a crash mid-migration can never leave the file at its old version with some of the new tables already present, a state this backend would otherwise treat as fresh and fail to re-create. A version outside 0..=SCHEMA_VERSION (including a negative value, which this backend never writes) refuses to open at all, before any write, naming the found version (sqlite.rs), a refusal pinned by a test that diffs the file’s bytes before and after it to prove nothing was touched.

A future bump means appending one more schema batch to MIGRATIONS, not introducing a new subsystem or a new arm to a hand-written match.

Optimistic concurrency, precisely

put’s update path (sqlite.rs:256-276) is a single conditional UPDATE:

UPDATE resources SET version = version + 1, value = ?4
WHERE kind = ?1 AND id = ?2 AND version = ?3

If rows_affected comes back 0, a follow-up SELECT version tells the two possible causes apart: no row at all (NotFound) versus a row that exists at a different version (VersionConflict { expected, actual }). delete (sqlite.rs) is the identical pattern with a conditional DELETE. A create (expected_version: None) is a plain INSERT, whose rusqlite::Error for a primary-key collision is mapped to AlreadyExists (sqlite.rs). put_batch serializes every entry to JSON before opening a transaction (so a serde failure never leaves one open), then runs one INSERT per entry inside it, dropping the transaction uncommitted on any failure (sqlite.rs), which is what makes it atomic where the port’s own default loop is not.

Dead letters and checkpoint provenance are inside the blob, not a column

record_dead_letter/update_dead_letter serialize the whole DeadLetter struct (match_id, monitor, sink, cursor, attempts, reason, and the optional payload) into the single entry TEXT column (sqlite.rs). A query that needs to find one letter by match_id reaches into that blob with sqlite’s own json_extract, rather than a dedicated column:

DELETE FROM dead_letters
WHERE pipeline = ?1 AND json_extract(entry, '$.match_id') = ?2

(sqlite.rs:454-480, comment noting this scan is accepted at triage-scale, with a dedicated index left for whichever deployment first proves it necessary). Because DeadLetter.payload: Option<SinkEvent> is #[serde(default, skip_serializing_if = "Option::is_none")] (crates/blockwatcher-types/src/event.rs), a dead letter recorded before replay support existed has no SQL NULL to check at all: its entry blob simply has no "payload" key, and serde_json::from_str fills that gap back in as None on read via #[serde(default)]. A payload that is a tagged SinkEvent or a legacy bare Match object both load; see Delivery guarantees § What a sink receives. The 409 checkpoint_provenance check the HTTP API documents (which module wrote a checkpoint) works the same way: Checkpoint.module: Option<String> lives inside the checkpoints.checkpoint JSON blob (sqlite.rs), and this crate stores and returns it opaquely: the module comparison itself happens one layer up, in the engine that reads the decoded Checkpoint back out.

Cursor representation

Cursor { primary: u64, secondary: u64 } never gets its own columns in either backend: in MemoryStorage it is a plain, unserialized struct field inside the in-memory Checkpoint; in SqliteStorage it exists only as the nested {"primary": N, "secondary": N} object inside the whole Checkpoint’s serialized JSON blob, the same shape the HTTP API’s checkpoint field shows on the wire.

Connection handling

SqliteStorage holds Arc<Mutex<rusqlite::Connection>>: one connection, not a pool (sqlite.rs). Every port method funnels through a shared with_conn helper that clones the Arc, locks the mutex, and runs the blocking sqlite call inside tokio::task::spawn_blocking, so the async runtime is never blocked on file I/O even though the connection itself serializes every access (sqlite.rs). with_conn also takes a read guard on a tokio::sync::RwLock<()> fence before spawning, and moves the guard into the blocking closure rather than merely holding it across the .await: if the caller awaiting with_conn is aborted, the closure keeps running on the blocking pool regardless, and the guard travels with it, so it still marks the call as in flight until the closure actually returns. SqliteStorage’s Storage::quiesce override takes the fence’s write side and immediately drops it, which a fair, write-preferring RwLock resolves only once every read guard taken before it has been dropped: this is what lets a caller that just escalated a drain to abort() wait out whatever storage call that abort left running, rather than risk a successor racing it. PRAGMA journal_mode=WAL is set at open for a file-backed database (skipped for :memory:, which cannot run WAL), and busy_timeout is set from the configured busy_timeout_ms (default 5_000) to soften contention from an external reader, not to support two blockwatcher processes writing the same file, which this backend does not support at all (sqlite.rs).

Neighbours

blockwatcher-storage depends on, in production:

  • blockwatcher-types: vocabulary crate (Checkpoint, DeadLetter, MatchId, NetworkId, PauseTarget, ResourceKind, VersionedRecord)
  • blockwatcher-ports: the Storage trait this crate implements twice
  • async-trait: required to implement the async fn-bearing Storage trait
  • serde: (de)serialization derive for each backend’s Config
  • serde_json: the JSON representation every stored value, checkpoint, and dead letter serializes to
  • tokio (rt feature): spawn_blocking for the sqlite backend’s connection access
  • rusqlite: the sqlite driver itself, this crate’s one check-dep-graph.sh family exemption

and, in [dev-dependencies] only:

  • blockwatcher-ports (fakes feature): blockwatcher_ports::fakes::MemoryStorage, run through the same shared contract test as this crate’s own two backends, for parity
  • blockwatcher-testkit: dead_letter (a fixture builder) and exercise_storage_contract, the shared behavioral-contract suite both backends are proven against
  • tokio (macros/rt/time/test-util features): async tests
  • tempfile: a real filesystem path for sqlite’s file-backed tests

The following crates depend on it directly (per the dependency table):

  • blockwatcher-embed: folds both backends into the catalog build_catalog returns
  • blockwatcher (binary): reaches storage both directly and through embed

Reading the source

  1. Start at lib.rs: three pub mod declarations and nothing else; the module doc names the categories this crate persists.
  2. registry.rs: storages::get_all, and the self-verifying test that constructs every registered backend from its own documented example config.
  3. memory.rs: MemoryStorage, State, and every Storage method in file order; read this first, since every method here is the simplest possible correct implementation of the same contract sqlite.rs implements durably.
  4. sqlite.rs: SCHEMA_V1/SCHEMA_VERSION, open_blocking (the migration logic), then every Storage method; read the module doc comment first for the single-writer trade-off this backend accepts.
  5. tests/contract.rs (workspace-shared, via blockwatcher-testkit): the one suite both backends (and the ports fakes::MemoryStorage) run through, proving they satisfy an identical contract by shared test rather than shared code.

blockwatcher-sinks

blockwatcher-sinks implements the Sink port once per module it ships: webhook, script, and log (crates/blockwatcher-sinks/src/registry.rs). Each sink delivers one sink event, once, and reports a classified failure: the engine owns every retry, every backoff, and every dead-letter decision, so a sink module that retries internally is a bug (lib.rs). It is a module crate: adding, removing, or changing one sink module never touches blockwatcher-core.

Delivery guarantees already documents the generic contract every sink goes through from the engine’s side: deliver_with_retry (crates/blockwatcher-core/src/pipeline/delivery.rs) is the one retry loop every sink call passes through, and exhausting it is what produces a DeadLetter. This page does not restate that loop; it covers what is specific to each sink module: the wire format it sends, its own timeout distinct from the engine’s retry backoff, and exactly what each module classifies as retryable versus permanent, since that classification is what deliver_with_retry acts on.

Key takeaways

  • blockwatcher-sinks implements the Sink port once per module it ships: webhook, script, and log. Each delivers one SinkEvent, once, and reports a classified failure; the engine owns every retry, backoff, and dead-letter decision.
  • A sink module that retries internally is a bug: returning from deliver after exactly one attempt is the contract every module here follows.
  • This page covers what is specific to each sink module (wire format, timeout, retry classification); Delivery guarantees already documents the generic engine-side contract.

Responsibilities

  • Register exactly the sink modules it ships ("log", "webhook", "script") under the same family-enumeration convention every module crate follows (registry.rs).
  • Render every event the identical way, once, for every sink to reuse: canonical_body serializes an blockwatcher_types::SinkEvent to tagged JSON (type: match | retracted | digest) and is the one wire rendering every sink that touches the network or another process uses (lib.rs).
  • webhook: POST the canonical body to a secret-referenced URL, with operator-configured headers and its own request timeout (webhook.rs).
  • script: pipe the canonical body (or a rendered body_template) to a subprocess’s stdin under a configured timeout, and read its exit code (or lack of one) as the delivery outcome (script.rs).
  • log: write one line of the canonical body (or a rendered body_template) to stdout, serialized against concurrent LogSink instances by a process-wide lock so two deliveries can never interleave mid-line (log.rs).
  • Classify every failure (an HTTP status, a transport error, a process exit code, an I/O error) into the shared ErrorClass the engine’s retry loop reads, at the point closest to the fact that produced it (each module’s own logic; see Per-sink delivery semantics below).

Not this crate’s job: retrying a failed delivery, deciding when enough attempts have been spent, or writing a DeadLetter: blockwatcher-core’s sink_worker.rs/delivery.rs own every one of those, and every sink here returns from deliver after exactly one attempt, success or failure (lib.rs); defining Sink, SinkError, or ErrorClass: those are blockwatcher-ports, only used here; resolving an env: secret reference into a value: blockwatcher_types::SecretRef does that, called by webhook.rs per delivery (see webhook).

Key types and traits

NameKindRole
canonical_bodyfnThe one wire rendering of a SinkEvent every sink uses; tagged JSON is the wire-contract anchor (lib.rs)
compile_body_templatefnParses and write-time-validates a body_template source against every deliverable event shape; shared by every module that gains the field, currently webhook, script, and log (lib.rs)
render_body_templatefnRenders a validated body_template environment against one real event at delivery time; shared the same way (lib.rs)
webhook::WebhookSinkstructSink impl: POSTs the canonical body to a secret-referenced URL (webhook.rs)
webhook::RegistrystructModuleRegistry impl exposing NAME = "webhook" (webhook.rs)
script::ScriptSinkstructSink impl: pipes the canonical body to a subprocess’s stdin (script.rs)
script::RegistrystructModuleRegistry impl exposing NAME = "script" (script.rs)
log::LogSinkstructSink impl: writes one canonical-JSON (or rendered body_template) line to stdout (log.rs)
log::RegistrystructModuleRegistry impl exposing NAME = "log" (log.rs)
registry::sinks::get_allfnEnumerates every registered sink module for a composition root’s boot-time catalog (registry.rs)

Every module’s own Config struct (webhook.rs, script.rs, log.rs) is private, not pub: reachable only through its ModuleRegistry::factory, documented via its own registry_examples/*.json rather than as a public type. There is no crate-local error or classification type: SinkError/ErrorClass/Classify are all blockwatcher-ports concepts this crate only uses.

The Sink port and what “delivered vs. dead-lettered” hinges on

Sink is one method (crates/blockwatcher-ports/src/sink.rs:6-12):

#![allow(unused)]
fn main() {
#[cfg_attr(feature = "testing", mockall::automock)]
#[async_trait]
pub trait Sink: Send + Sync {
    /// Deliver one event. The ENGINE owns retry/backoff/dead-letter policy;
    /// the sink reports classified errors and does not retry internally.
    async fn deliver(&self, event: &SinkEvent) -> Result<(), SinkError>;
}
}

SinkError (blockwatcher-ports/src/error.rs) is Delivery { message, class: ErrorClass } or InvalidConfig { message }, and its Classify impl reports class for Delivery and always Permanent for InvalidConfig. So structurally, “delivered” is deliver returning Ok(()); “dead-lettered” is the engine’s deliver_with_retry exhausting its budget on a stream of Errs whose ErrorClass values it read straight off each module’s own return: every classification decision a sink module makes here is a direct input into that outcome, one module never sees the other’s retries, and none of the shipped modules ever emits ErrorClass::RetryNarrower (that class exists for RPC calls whose request can shrink, not deliveries).

Every delivery attempt, across every sink module, funnels through the same shape:

flowchart LR
    attempt["deliver(event)<br/>one attempt"] -->|"Ok(())"| delivered["delivered"]
    attempt -->|"Err(SinkError)"| classify{"ErrorClass"}
    classify -->|"Transient / RateLimited"| retry["engine retries<br/>with backoff"]
    classify -->|"Permanent"| dead["dead letter"]
    retry -->|"attempts exhausted"| dead
    retry --> attempt

Per-sink delivery semantics

webhook

Config (webhook.rs, deny_unknown_fields): url_secret: String (an env:NAME reference, resolved fresh at every delivery: no resolved copy outlives one request), headers: BTreeMap<String, String> (default empty, plain values), header_secrets: BTreeMap<String, String> (default empty, env:NAME references resolved fresh at every delivery like url_secret; a name also present in headers refuses the write), timeout_ms: u64 (default 10_000, rejected if 0 at construction since it would make every attempt time out immediately), body_template: Option<String> (a minijinja template validated at write time; absent keeps the canonical SinkEvent JSON unchanged). WebhookSink itself stores the unresolved SecretRefs, never a resolved URL or header value (webhook.rs).

deliver (webhook.rs) resolves the secret, parses it as a URL, builds body = canonical_body(event)?, and sends POST <url> with Content-Type: application/json plus every configured header, body exactly the canonical JSON with no wrapping or renaming. Redirects are disabled outright: following one would re-send the secret-addressed request to a host the operator never named, so any 3xx is a visible, permanent failure (webhook.rs, classify_status). Retry classification (classify_status, webhook.rs):

ResponseClass
429 Too Many RequestsRateLimited
408 Request Timeout, any 5xxTransient
anything else (including every 3xx)Permanent
a transport-level reqwest::Error (e.g. connection refused)Transient, unconditionally (transport_error, webhook.rs)

The timeout_ms config bounds one HTTP attempt via reqwest::Client::builder().timeout(...); it is set once, at construction, and is orthogonal to blockwatcher-core’s own retry backoff between attempts.

An optional config field, body_template (optional string, absent by default), is a minijinja template for the request body. It renders against a JSON value (canonical_value, lib.rs) that shares SinkEvent’s serde shape with the wire body but is serialized independently of it: canonical_body serializes the event directly, never through this serde_json::Value, because routing it through Value would re-sort top-level keys and break the byte-pinned wire contract (lib.rs). So a template and the default wire body always see identical field names: for a match, top-level type, id, monitor, network, and event (with event.kind, event.name, event.fields, event.cursor); for a retracted event, type and match_id; for a digest, type and matches, each element shaped like a match without its type. A template branches per shape with a guard — {% if type == "digest" %}…{% endif %} — whose digest branch renders only against digest events. Validation and rendering split by when each runs:

  • Write time (Registry::factory, webhook.rs, calling compile_body_template("webhook", source) in lib.rs): a configured body_template is parsed and rendered against three synthetic events — a match, a retracted event, and a two-match digest (synthetic_validation_events, lib.rs) — before construction returns, so every shape a sink can be handed is exercised at the write. A syntax error or a filter that does not exist fails the write as SinkError::InvalidConfig (an API 422), message-prefixed invalid webhook body_template:. A configuration mistake surfaces at the write that introduced it, never as a dead-lettered delivery later.
  • Delivery time (deliver, webhook.rs, calling render_body_template in lib.rs): a template that passed write-time validation still renders fresh per real event. A render failure there is a permanent delivery error, body_template render failed: {e}: deterministic over the same payload, so a retry cannot change the outcome.
  • Undefined fields render empty. The template environment sets UndefinedBehavior::Lenient: a field the template reads that a given event lacks (a retracted event has no network, for instance) renders that lookup as empty rather than failing the render. A template is presentation over the canonical event, and a missing decoration must not dead-letter a correct match. Leniency covers plain printing and iteration only: piping an undefined field through a filter still errors (e.g. {{ event.fields.map.amount.uint | int }} fails write-time validation against the synthetic events, neither of which has that field) — | default(...) before the filter is the escape hatch.
  • Absent body_template keeps the wire byte-identical. With no template configured, deliver sends canonical_body(event) unchanged.

A template replaces the canonical wire contract with an operator-authored one — the canonical body remains the default and the only shape the compatibility test pins.

A worked example: the template

{"text": "{{ type }} on {{ network }}"}

validates at write time against both synthetic events, and against the pinned match fixture (monitor m, network net) renders the POST body:

{"text": "match on net"}

type and network above are operator-known enum-shaped strings, safe to interpolate bare inside hand-written quotes. event.fields and event.name are decoded chain data, not operator-controlled: a value there can carry a quote or brace, so interpolating one into a JSON payload without escaping can produce malformed JSON or let the value inject structure into the destination payload (a forged Slack block, for instance). Pipe a chain-derived value through |tojson instead of wrapping it in hand-written quotes — tojson supplies its own quoting, so it replaces the surrounding "..." rather than nesting inside them:

{"text": {{ type | tojson }}}

renders {"text": "match"}; used on a value that actually contains a quote, the equivalent hand-written-quotes form would break the JSON.

script

Config (script.rs, deny_unknown_fields): command: String, args: Vec<String> (default empty, passed verbatim), timeout_ms: u64 (default 30_000), body_template: Option<String> (absent by default). No env field: the child inherits the process’s own environment; no working-directory field.

deliver (script.rs) spawns tokio::process::Command::new(&command).args(&args) with stdin piped, stdout discarded, stderr captured (a 4 KiB tail), and kill_on_drop(true). With no body_template configured, canonical_body(event)? is written to the child’s stdin, not argv and not an environment variable; with one configured, render_body_template’s rendered string is written instead. Either way stdin is shut down once the write completes. The whole write-plus-wait is wrapped in tokio::time::timeout(self.timeout, ...); on elapse the child is killed via the drop guard and the outcome is Transient (“script timed out after {ms}ms”). Exit-code classification is an explicit, operator-facing contract, not transport inference:

OutcomeClass
exit code 0delivered (Ok(()))
exit code 75 (EX_TEMPFAIL)Transient (the script’s own “please retry” signal)
any other exit codePermanent
killed by signal (includes the timeout-kill path)Transient
spawn failure: command not found or permission deniedPermanent
spawn failure: anything elseTransient

body_template is the same minijinja mechanism webhook uses (see webhook for the context shape, the digest type guard, and the lenient-undefined rules), through the same shared compile_body_template/render_body_template functions in lib.rs: write-time validation against the three synthetic events, invalid script body_template: ... on a syntax or filter error at the write, and a permanent body_template render failed: {e} at delivery if a real event’s shape ever slips past that validation. The one difference is what the rendered text becomes: a request body has to stay valid JSON for the receiving webhook, but a script’s stdin has no inherent structure to protect — an operator whose script parses stdin as JSON (or any other structured format) still owns the same |tojson-style escaping discipline webhook’s worked example shows, since decoded chain data is not operator-controlled and can carry a quote or brace.

The sink-script-monitor example configures a body_template that renders each match as a Match |- Transfer from … to … of … USDC line (and a digest as one Digest |- … line plus one |- … continuation line per bundled match), so the example script simply appends the rendered text — no jq or other post-processing needed on the receiving end.

log

Config (log.rs): body_template: Option<String> (absent by default) is its one field; deny_unknown_fields still rejects any other key as a boot-time typo rather than a silent ignore. LogSink itself carries the compiled template (Option<minijinja::Environment<'static>>), so unlike webhook/script it is no longer a unit struct — tests construct it via LogSink::default(). line (log.rs) builds the emitted line: with no template, canonical_body(event)? plus a trailing newline; with one, render_body_template’s rendered string plus the same trailing newline. deliver writes that line directly to tokio::io::stdout(), under a process-wide AsyncMutex<()> that serializes concurrent LogSink instances so two deliveries can never interleave mid-line. It is not routed through the tracing/log facade at all.

body_template here is the same mechanism documented under webhook and script, through the same shared compile_body_template/render_body_template functions in lib.rs: write-time validation against the three synthetic events, an invalid log body_template: ... message on a syntax or filter error at the write, and a permanent body_template render failed: {e} at delivery if a real event’s shape ever slips past that validation.

Delivery can fail, narrowly: an I/O error writing or flushing stdout (a broken pipe, most plausibly) maps to Transient rather than Permanent (a deliberate loss-aversion choice, since an under-classified retry costs one wasted attempt while an over-eager Permanent would dead-letter a match a momentarily-blocked consumer would otherwise have accepted), in the transient helper (log.rs).

The canonical_body wire-contract test

Two layers pin the same shape:

  • A crate-internal unit test, canonical_body_match_is_tagged (lib.rs), calls canonical_body directly against a fixed SinkEvent::Match and asserts the resulting bytes, parsed back to JSON, equal a hand-written literal that includes "type": "match". A second test, canonical_body_retracted_is_tagged, pins {"type":"retracted","match_id":"…"}. A third, canonical_body_digest_is_tagged, pins a SinkEvent::Digest of two matches to a literal with "type": "digest" and a matches array of their own canonical objects, each rendered exactly as the lone-match literal renders one. Together they pin both the field shape of blockwatcher_types::Match/DecodedEvent/SinkEvent and the exact hash MatchId::derive produces for that fixed input ("14e563bd9d376e738be5e13e4054327c").
  • An integration-level echo of the identical literal through a real HTTP round trip, a_delivery_posts_the_canonical_body_exactly_once (tests/sinks/webhook.rs): a mock axum server records what WebhookSink::deliver actually sent, and the test asserts the received body equals the same pinned literal, byte for byte.

Every other body-comparing test in the crate re-serializes the input and compares against that: those tests would keep passing through a shape change that these two would catch. That is the point of keeping one literal assertion rather than none: the sink modules render SinkEvent to something outside the process, which makes its serde shape an external wire contract the moment any one of them ships, and a hand-written literal is the only form of assertion that a coordinated rename (changing both the struct and every call site that serializes it) cannot silently slip past.

Neighbours

blockwatcher-sinks depends on, in production:

  • blockwatcher-types: vocabulary crate (SinkEvent, SecretRef)
  • blockwatcher-ports: the Sink trait this crate implements once per module, and ErrorClass/SinkError
  • async-trait: required to implement the async fn-bearing Sink trait
  • serde: (de)serialization derive for each module’s Config
  • serde_json: the canonical wire format canonical_body produces
  • tokio (io-util/io-std/process/time/macros features): stdin piping and process timeout (script), async stdout (log)
  • reqwest: the webhook module’s HTTP client, this crate’s one check-dep-graph.sh family exemption
  • minijinja (default-features = false, builtins + serde + json features): the webhook module’s optional body_template renderer; json gates the |tojson escaping filter separately from builtins and depends on serde_json, already in the tree, so memo-map remains the only wholly new transitive dependency — no network, TLS, or async-runtime dependency of its own

and, in [dev-dependencies] only:

  • tokio (macros/rt/net/time features): async tests and the mock server’s listener
  • axum: a mock HTTP receiver webhook’s tests assert against, not a client
  • tempfile: filesystem fixtures for script’s tests
  • blockwatcher-core: exercises a real sink through blockwatcher-core’s actual Engine/sink-worker/deliver_with_retry machinery rather than a reimplemented harness; blockwatcher-core itself may never depend on a module crate, so this dev-only edge crosses that seam from the module side instead, and is how the retry-then-dead-letter contract from Delivery guarantees gets proven end to end for webhook and script specifically
  • blockwatcher-ports (fakes feature): fake ports for the fake source/decoder/matcher/storage blockwatcher-core’s test engine needs alongside a real sink
  • blockwatcher-testkit: shared test scaffolding
  • metrics: asserted against in the engine-delivery tests’ metric checks
  • tracing: structured logging the log-capture tests read
  • tracing-subscriber: captures log output for those assertions (e.g. confirming a resolved webhook URL never reaches a log line)

The following crate depends on it directly (per the dependency table):

  • blockwatcher-embed: the composition façade registers every sink module into the engine’s module catalog; the blockwatcher binary reaches this crate only through embed

Reading the source

  1. Start at lib.rs: the crate doc comment states the whole contract in one sentence, and canonical_body is the one function every sink module below calls before it sends anything anywhere.
  2. registry.rs: sinks::get_all, and its self-verifying test, which constructs every registered module from its own documented example config and panics on an unmatched name, the mechanism that keeps this enumeration and the registry_examples/*.json files from drifting apart.
  3. webhook.rs: WebhookSink, classify_status, transport_error; read the module doc comment first for why redirects are refused and for the split between plain headers and secret-referenced header_secrets.
  4. script.rs: ScriptSink, its exit-code contract (EXIT_TEMPFAIL), and the timeout-then-kill path; read the module doc comment for the inherited-environment and no-working-directory decisions.
  5. log.rs: LogSink, the process-wide STDOUT_LOCK, and why a broken pipe classifies Transient rather than Permanent.

blockwatcher-gates

blockwatcher-gates implements the Gate port once per module it ships: threshold and max_once (crates/blockwatcher-gates/src/registry.rs). Each module compiles a monitor’s gate.config against the same schemas the predicate uses, then answers on_hit over an engine-owned journal. The engine owns persistence, prune, and delivery: a module that opens sqlite, or that drains the journal in on_invalidate, is a bug (lib.rs). It is a module crate: adding, removing, or changing one gate module never touches blockwatcher-core.

Gates already documents the operator-facing contract: event time, one gate per monitor, outstanding 0 on quiet hits, and prune-by-cursor on invalidate. This page covers what is specific to the crate: registration, the two shipped modules, and the compile refusals they share.

Key takeaways

  • blockwatcher-gates implements the Gate port for threshold and max_once. Both depend on blockwatcher-types and blockwatcher-ports only, never blockwatcher-core.
  • Catalog fold is blockwatcher_gates::registry::gates::get_all() in crates/blockwatcher-embed/src/catalog.rs. There is no gates feature: both modules are always registered, the same way storage is.
  • Time-window gates require block.timestamp (unsigned or non-negative int) on the compiled schema. The write refuses with gate requires 'block.timestamp'; this monitor's selectors do not expose it.

Responsibilities

  • Register exactly the gate modules it ships ("threshold", "max_once") under the same family-enumeration convention every module crate follows (registry.rs).
  • threshold: session digest. Config { "count", "window_ms" }. Drop a prefix until the remaining span fits window_ms; if len >= count, Emit the oldest count indices; else Retain (threshold.rs).
  • max_once: first hit per event-time window. Config { "window_ms" }. Emit([this]) when last_emit_ts is none or this event_ts is outside the window; otherwise Discard([this]) (max_once.rs).
  • Share window_ms bounds (1..=86_400_000) and the block.timestamp schema check (lib.rs). threshold.count is 2..=10_000.

Not this crate’s job: persisting gate_hits / gate_meta, pruning on invalidate, minting Match ids, or stalling Progress on persist failure: blockwatcher-core’s pipeline/gate.rs and engine/invalidate.rs own those; defining Gate, GateDecision, GateHit, CompiledGate, GateAux, or GateError: those are blockwatcher-ports; constructing a passthrough: omit gate on the monitor, or use the ports fake (PassthroughGate) in tests.

Key types and traits

NameKindRole
threshold::ThresholdGatestructGate impl: N hits spanning ≤ window_ms of event time fire once, then the bag resets (threshold.rs)
threshold::RegistrystructModuleRegistry impl exposing NAME = "threshold" (threshold.rs)
max_once::MaxOnceGatestructGate impl: at most one alert per event-time window (max_once.rs)
max_once::RegistrystructModuleRegistry impl exposing NAME = "max_once" (max_once.rs)
registry::gates::get_allfnFamily enumeration folding both modules into a factory lookup table (registry.rs)

Neighbours

blockwatcher-gates depends on, in production:

  • blockwatcher-types: vocabulary crate
  • blockwatcher-ports: the Gate port and GateError
  • serde, serde_json: config (de)serialization

The following crate depends on it directly (per the dependency table):

  • blockwatcher-embed: folds get_all() into build_catalog

Using the crate without the engine

blockwatcher-gates depends on blockwatcher-types and blockwatcher-ports only, never blockwatcher-core. A host can compile and decide without the binary. Persist, prune, and Match mint stay the host’s (or core’s) job.

#![allow(unused)]
fn main() {
use blockwatcher_gates::threshold::ThresholdGate;
use blockwatcher_ports::{Gate, GateCtx, GateDecision, GateHit};

let gate = ThresholdGate;
let compiled = gate.compile(&config, &schemas)?;
let mut journal: Vec<GateHit> = Vec::new(); // host-owned

journal.push(hit);
match gate.on_hit(&compiled, &journal, &GateCtx { last_emit_ts: None }) {
    GateDecision::Emit { indices } => {
        // host mints Match / Digest from journal[indices]
        if let Some(max) = indices.iter().copied().max() {
            journal.drain(0..=max);
        }
    }
    GateDecision::Retain => {}
    GateDecision::Discard { .. } => {}
}
}

Reading the source

  1. lib.rs: crate contract (engine owns the journal), window bounds, and the shared block.timestamp check.
  2. threshold.rs, max_once.rs: one module each, config validation and on_hit.
  3. registry.rs: family enumeration build_catalog folds.

blockwatcher-api

blockwatcher-api is blockwatcher’s REST control plane: it turns an HTTP request into exactly one call against blockwatcher-core’s ControlHandle, or against core’s typed storage facade for a read, and turns whatever comes back into a JSON response. Every mutation goes through ControlHandle, the same path a --seed load and a running engine’s own boot use, so a resource never sees two independent validate/persist code paths; every read goes through core’s Resources facade, taking no engine lock, so a status or a GET stays answerable while a mutation is in flight (lib.rs).

Its production dependencies are exactly the names scripts/check-dep-graph.sh’s ALLOW_BLOCKWATCHER_API entry lists (crates/blockwatcher-api/Cargo.toml):

  • blockwatcher-types: every resource shape a route (de)serializes, and the ids used to look one up
  • blockwatcher-ports: Storage (the trait ApiState.storage is a dyn pointer to) and StorageError (matched inside error.rs’s classification)
  • blockwatcher-core: ControlHandle, EngineError, and every other engine type a route names, listed in full in Key types and traits below
  • axum: the HTTP framework the whole route table and serve are built on
  • serde: the derive machinery behind every request and response body
  • serde_json: parsing a request body and rendering a response one
  • tokio (net feature): TcpListener, the type serve accepts already bound
  • tracing: the crate’s own structured logging (an auth rejection, a 500’s real detail)

blockwatcher-api is one of the crates in the core ring, and the only one permitted to carry an HTTP stack at all: Workspace map § Three rings names the single exemption scripts/check-dep-graph.sh grants it, for exactly the axum family (axum, and what it pulls in) and no other forbidden family, so this crate can serve HTTP without ever being able to see a chain SDK or a storage driver either. See Architecture decisions § Chain knowledge stays out of the core for the rule this crate is the one core-ring exception to.

The HTTP API reference already documents every route, request and response body, and status code from an operator’s point of view. This page does not restate that table; it covers how the six route files split the work, how the auth middleware and the error mapping are built, and what keeps this crate itself chain-agnostic despite serving resources (a network’s source.config, a spec’s payload) that are opaque, module-specific JSON on the wire.

Key takeaways

  • Every mutation goes through ControlHandle, the same path a --seed load and boot use; every read goes through core’s Resources facade without taking an engine lock.
  • blockwatcher-api is the one core-ring crate allowed to carry an HTTP stack, exempted only for the axum family.
  • A network’s source.config and a spec’s payload cross this crate as opaque JSON; it never looks inside either one.
  • This page covers route organisation, auth, and error mapping; the HTTP API reference already documents every route from an operator’s point of view.

Responsibilities

  • Builds the complete route table and wraps it in one authentication middleware layer, router (lib.rs).
  • Enforces Authorization: Bearer <token> on every route but /health, matching the presented secret against the labelled [auth] table (auth/mod.rs) and inserting Identity { label, scope }. See Authentication middleware.
  • Maps every failure, engine-raised or decided by this layer on its own, onto one wire error shape and the right HTTP status (error.rs). See Error mapping.
  • Generates every resource kind’s whole CRUD surface from one macro, so PUT/GET/DELETE against a network, a spec, a sink, or a monitor are the same code path repeated rather than each hand-kept separately (routes/resources.rs).
  • Serves the route table on an already-bound listener until a shutdown future resolves, then lets in-flight requests finish (serve.rs), which is what keeps axum out of the blockwatcher binary’s own dependency graph (serve.rs).

Not this crate’s job: deciding whether a write is valid, compiling a predicate, or persisting anything: every one of those already happened inside ControlHandle by the time a handler’s await returns (blockwatcher-core); running a pipeline, a source, a decoder, a matcher, or a sink (also blockwatcher-core, dispatching into the module crates behind its port traits); knowing what any chain’s wire format means (no chain SDK appears anywhere in this crate’s dependency tree at all, per the exemption described above); exposing anything over Prometheus (blockwatcher-metrics owns a wholly separate axum::Router on its own socket); reading [api].listen or resolving [auth] from instance configuration (the blockwatcher binary does both and hands this crate an already-bound listener and an already-built token table, in boot, crates/blockwatcher/src/run.rs).

Key types and traits

NameKindRole
ApiStatestructEverything a handler needs: control (ControlHandle, the only mutation path), storage (Arc<dyn Storage>, what reads are served from), and auth (Arc<Vec<ApiToken>>, labelled credentials, never resolved secret values) (lib.rs)
routerfnBuilds the merged route table from all six route files and wraps the whole thing in the auth middleware (lib.rs)
serveasync fnRuns router(state) on an already-bound TcpListener until shutdown resolves, via axum::serve(..).with_graceful_shutdown(..) (serve.rs)
require_bearerasync fn (middleware)The one authentication check every route but /health passes through (auth/mod.rs). Inserts Identity { label, scope }.
require!macroPer-MethodRouter authorization: .route_layer(require!(Scope::Admin)) (auth/mod.rs)
ApiErrorenumEngine(EngineError) or Api { status, code, message }: every way a request can fail, collapsed to one type so every failure leaves through one body shape (error.rs)
ErrorBody, ErrorDetailstructThe wire error shape: {"error": {"code", "message", "actual_version"?}} (error.rs)
resource_routes!macroGenerates one resource kind’s list/read/write/remove handlers and the routes they answer on, invoked once per kind (routes/resources.rs)

Route organisation

routes/ holds one file per concern, and routes::mod’s own module list is the complete inventory (routes/mod.rs). Each file answers a distinct question, which is also why a new resource kind or a new cross-cutting concern gets a new file rather than a growing one:

FileAnswersRoutes
health.rsIs this process up at all?GET /health, exempt from auth
status.rsWhat is every pipeline doing right now?GET /status
schema.rsWhat vocabulary can a predicate against this spec address?GET /specs/{id}/schema
resources.rsCreate, read, update, delete, list, for any resource kindGET/PUT/DELETE on /networks, /specs, /sinks, /monitors (collection and {id})
monitors_ops.rsWhat can an operator do to one monitor beyond CRUD?POST /monitors/{id}/pause, /resume, /test
networks_ops.rsWhat can an operator do to one network beyond CRUD?POST /networks/{id}/pause, /resume, /skip; DELETE /networks/{id}/checkpoint; GET/DELETE on /networks/{id}/dead-letters (collection) and POST .../replay/DELETE on /networks/{id}/dead-letters/{match_id}

The split mirrors the same distinction blockwatcher-core’s own control/ module draws between generic CRUD (writes.rs/deletes.rs, one write and one delete method per kind) and kind-specific extra verbs (skip.rs, dead_letters.rs): resources.rs is one macro invoked four times because every kind’s CRUD answers identically, while monitors_ops.rs and networks_ops.rs exist precisely because a monitor’s extra operations (pause, resume, a dry-run test) and a network’s (pause, resume, skip, checkpoint reset, dead-letter replay) are not the same set and must not be forced into one shared shape just because they are both “operations on a resource.” health.rs, status.rs, and schema.rs each get their own file for the same reason blockwatcher-core’s progress.rs sits apart from pipeline/: none of the three is CRUD or an operation on a stored resource at all, and folding a liveness probe or a read-only snapshot into a file named for something else would make that file’s own scope harder to state.

Every route file exports one router() -> axum::Router<ApiState>, and lib.rs::router does nothing but .merge all six together and layer the auth middleware on top (lib.rs):

flowchart LR
    req(("incoming<br/>request")) --> mw{"require_bearer<br/>auth/mod.rs"}
    mw -->|"path is '/health'"| health["health::router()<br/>routes/health.rs"]
    mw -->|"any other path,<br/>valid bearer"| merged{{"merged route table<br/>lib.rs"}}
    mw -->|"missing or wrong<br/>bearer token"| err401["401 unauthorized<br/>WWW-Authenticate: Bearer"]
    merged --> status["status::router()"]
    merged --> schema["schema::router()"]
    merged --> resources["resources::router()<br/>(every kind via one macro)"]
    merged --> monops["monitors_ops::router()"]
    merged --> netops["networks_ops::router()"]

resources.rs additionally carries the shared helpers every one of its four generated kinds calls through: if_match/required_if_match (parsing and requiring a strong numeric ETag out of If-Match), written (201 for a create versus 200 for an update), found (the bare resource plus its version in an ETag), parse (a body that fails to deserialize becomes an EngineError::InvalidResource naming the offending key), and same_id (a path id and a body id that disagree is refused rather than silently resolved one way or the other) (resources.rs).

Authentication middleware

require_bearer (auth/mod.rs) is axum::middleware::from_fn_with_state, layered once around the entire merged router rather than mounted only on the routes that need it (lib.rs). The /health exemption is a path check inside the middleware, ahead of the router, not a separate mount alongside it: a request to a path that does not exist at all is refused for lacking a token before the router gets a chance to reveal, via a 404 versus something else, whether the path would have matched anyway (auth/mod.rs).

For any other path, the middleware:

  1. Resolves state.auth (a SecretRef) to the expected token, fresh, on this request. A resolution failure here is unreachable once actually serving traffic, since the composition root resolves the same reference at boot and refuses to start without it (crates/blockwatcher/src/config.rs’s InstanceConfig::auth); the defensive path that remains answers 500 rather than panicking (auth/mod.rs).
  2. Reads Authorization, and accepts only the bearer scheme, case-insensitively, per RFC 7235 (bearer_credential, auth/mod.rs).
  3. Compares the presented token against the expected one in constant time on length-equal inputs (constant_time_eq, auth/mod.rs): the only thing an unequal-length comparison leaks is that the lengths differ, never which byte.
  4. On a match, calls next.run(request); on anything else, answers unauthorized (auth/mod.rs): a 401 with WWW-Authenticate: Bearer, and a tracing::warn! naming the method and path but never the presented or expected token in any form, because a rejected credential is still a secret and one that merely arrived at the wrong deployment is often the right token somewhere else (auth/mod.rs).

The token itself is never held between requests: ApiState.auth carries only the SecretRef (the env:NAME reference), and step 1 above resolves it anew on every single request that isn’t /health (lib.rs).

Error mapping: from EngineError to HTTP status

ApiError (error.rs) is the one type every handler’s Result resolves its error side to, whether the failure came from the engine (ApiError::Engine, via From<EngineError>) or was decided by this layer on its own (ApiError::Api { status, code, message }, used for a malformed If-Match, a missing one on a delete, or a genuinely internal condition). classify (error.rs) is the exhaustive match from every EngineError variant onto its status and wire code, with no catch-all arm: a new variant added to blockwatcher-core fails this crate’s build until someone gives it a status here, rather than silently inheriting whatever a neighbouring arm happens to answer.

EngineError variantStatusCode
UnknownModule, InvalidResource, UnsupportedChain, Compile, ModuleInit, MissingReference, StillReferenced422unknown_module / invalid_resource / unsupported_chain / compile_failed / module_init_failed / missing_reference / still_referenced
Storage(VersionConflict)412version_conflict
Storage(NotFound)404not_found
Storage(AlreadyExists)409already_exists
ShuttingDown503shutting_down
Unavailable503unavailable
NoRunningPipeline404no_running_pipeline
ReplayFailed502replay_failed
Conflict409conflict
CheckpointProvenance409checkpoint_provenance
Storage(Backend), Storage(InvalidConfig), DuplicateModule, DuplicateChainDecoder, InvalidEngineConfig500internal

The first group is every refusal a caller caused and can fix by editing the request; the last group is a broken invariant of the process itself (the duplicate-registration pair cannot even arise from a request, since a boot that reached the point of serving already rejected them), given explicit arms rather than folded into a catch-all so a future variant cannot land there by default (error.rs, comment).

One case gets special handling ahead of classify: ApiError::stored (error.rs), which every handler that reads or writes storage calls instead of the plain From conversion. It rewrites InvalidResource specifically to a 500, because arriving there from a stored record (as opposed to a caller-supplied body, which handlers reject earlier through parse and its own From) can only mean a record already in storage no longer deserializes: an older binary’s row, a hand edit, a newer schema. That is the deployment’s problem, not the request’s, so it goes to the log at error level and the wire gets the generic 500 body instead of a 422 that would blame the caller for state they never touched and cannot fix. ApiError::from(EngineError) (the plain, un-rewritten path) is reserved for the handlers where an InvalidResource genuinely is the caller’s own doing (the skip handler maps its rewind refusals through plain ApiError::from; the replay and monitor-test handlers do the same through their own small mappers, replay_error in routes/networks_ops.rs and test_error in routes/monitors_ops.rs), so the choice of mapper at each call site encodes which side of that distinction a given handler is on.

body() (error.rs) renders the fixed {"code":"internal","message": "internal error"} for any 500, regardless of which branch produced it, and into_response (error.rs) is the one place the real detail (a storage backend’s message, a connection string, a stored record’s identity) reaches tracing::error! before the response goes out: the log gets everything, the wire gets nothing past the fixed string. Every other status keeps its variant’s own Display text as message, and actual_version (error.rs) is populated only for Storage(VersionConflict), carrying the version storage actually found so a caller’s retry can send the If-Match it should have sent the first time.

Staying chain-agnostic

blockwatcher-api sits in the core ring specifically because it is not permitted to know what any chain’s wire format means, and nothing about how it is built requires it to: every handler speaks in blockwatcher-types resource shapes (Network, Spec, SinkDef, Monitor) and calls exactly two engine surfaces, ControlHandle for a mutation and Resources for a read, neither of which exposes anything chain-specific in its own signature. A few concrete details make this a property of the code, not just a stated intention:

  • A network’s source.config and a spec’s payload cross this crate as opaque serde_json::Value fields inside their resource shape (blockwatcher_types::Network/Spec): this crate never looks inside either one. Whether source.config parses as a valid evm-rpc pool, or payload as a valid Solidity ABI array, is decided by the named module’s own code, reached through ControlHandle::put_network/put_spec, never by anything in routes/.
  • GET /specs/{id}/schema (routes/schema.rs) is the one route that looks like it might hand back chain detail, and deliberately doesn’t: it calls ControlHandle::spec_schema, which compiles the spec through its chain’s own decoder into blockwatcher_types::SchemaSet, a chain-agnostic field/type-name vocabulary. It serves the decoder’s translation of the artifact, never the artifact itself, which is exactly what keeps this route’s response shape identical whether the spec behind it is an EVM ABI or something no chain family has been written for yet, per the schema handler’s own doc comment (routes/schema.rs).
  • Nothing in this crate’s dependency tree can reach a chain SDK at all: the exemption scripts/check-dep-graph.sh grants it covers the axum family only, so a chain SDK pulled in anywhere in this crate’s tree would fail the same transitive check that already forbids it for blockwatcher-core itself (see the dependency list at the top of this page, and Workspace map § Three rings).

Neighbours

blockwatcher-api depends on, in production (the same eight-entry list named in full above):

  • blockwatcher-types
  • blockwatcher-ports
  • blockwatcher-core
  • axum
  • serde
  • serde_json
  • tokio (net feature)
  • tracing

and, in [dev-dependencies] only:

  • blockwatcher-testkit: shared test scaffolding
  • blockwatcher-ports (fakes feature): the in-memory Storage/Source/etc. fakes a route handler test runs against instead of a real module
  • async-trait: implementing test-only trait impls the fakes crate doesn’t already provide
  • reqwest: driving the router over real HTTP in this crate’s own tests
  • tokio (macros/rt-multi-thread/time features): async test execution

The following crate depends on it directly (per the dependency table):

  • blockwatcher (binary): boots the engine, builds a ControlHandle, binds the API’s listener, and hands both plus the resolved auth reference to blockwatcher_api::serve as ApiState, in boot and serve_api (crates/blockwatcher/src/run.rs)

Reading the source

  1. lib.rs: the module doc comment, ApiState, and router: the whole crate’s shape in one file.
  2. auth/: require_bearer and require! (mod.rs), Scope (scope.rs), and the audit trail (audit.rs).
  3. error.rs: ApiError, classify, and stored versus from: read this before any route file, since every fallible handler returns ApiError.
  4. routes/mod.rs, then the files it lists, in the order Route organisation above covers them: health.rs, status.rs, schema.rs, resources.rs (the macro, then its invocations, one per resource kind), monitors_ops.rs, networks_ops.rs.
  5. serve.rs: the graceful-shutdown wrapper the blockwatcher binary calls once the API listener is bound and the engine is up.

blockwatcher-embed

blockwatcher-embed is the in-process composition façade: it folds every compiled-in module family into one ModuleCatalog, and it re-exports the engine types a host needs to boot and stop without linking the blockwatcher binary. The binary itself calls build_catalog here rather than keeping a second registrar (crates/blockwatcher/src/run.rs).

It sits in the glue ring, the same ring as the binary: it may depend on blockwatcher-core, and scripts/check-dep-graph.sh allowlists it that way. It does not depend on blockwatcher-api, axum, the metrics HTTP listener, CLI parsing, or process signals.

Its production dependencies are exactly the ALLOW_BLOCKWATCHER_EMBED entry scripts/check-dep-graph.sh lists (crates/blockwatcher-embed/Cargo.toml):

  • blockwatcher-core: Engine, EngineDeps, Engine::start, ControlHandle, ModuleCatalog
  • blockwatcher-storage: always folded into the catalog (memory, sqlite); not behind a feature
  • blockwatcher-gates: always folded into the catalog (threshold, max_once); not behind a feature — there is no gates flag
  • blockwatcher-expr, blockwatcher-evm, blockwatcher-sinks: optional (dep: in [features]), one per feature flag, mirroring the binary

The family exemption list (FAMILY_EXEMPT_BLOCKWATCHER_EMBED) covers the module crates this façade links (alloy, reqwest, hyper, tower-http, rusqlite) and not axum: embed never serves HTTP.

Embedding blockwatcher in a host process already covers this crate from a host’s side: the boot recipe, an in-process Sink, and how shutdown differs from the binary. This page does not restate that recipe; it covers what the crate actually exports, which feature flag removes which registration, and where it sits in the dependency graph.

Key takeaways

  • blockwatcher-embed is glue, not a module: it may depend on blockwatcher-core, and it is the one place module families are registered.
  • Default features (evm, expr, sinks) mirror the binary; storage and gate modules (threshold, max_once) are always present. There is no gates feature: turning a gate module off is not a compile-time switch; an unknown gate.module at write is 422 listing catalog names.
  • It re-exports build_catalog, DEFAULT_MATCHER (when expr is on), Engine, EngineDeps, EngineConfig, EngineError, and ControlHandle. It does not wrap Engine::start.
  • The binary is a client of this crate for catalog construction. Nothing in the workspace depends on embed except the binary.

Responsibilities

  • Registers every compiled-in module family into one ModuleCatalog, feature-gated so what a build can select is exactly what it linked (catalog.rs).
  • Re-exports the small set of engine types a host needs for the happy path (lib.rs).
  • Forwards the binary’s evm / expr / sinks feature flags onto the same optional module crates, so a binary built without evm also builds embed without evm (crates/blockwatcher/Cargo.toml).

Not this crate’s job: parsing CLI or instance config, seeding a directory, binding listeners, installing SIGTERM/SIGINT, or serving HTTP or Prometheus (blockwatcher binary, blockwatcher-api, blockwatcher-metrics); running a pipeline or deciding when a checkpoint advances (blockwatcher-core); implementing any module a catalog can select (each lives in its own module crate).

Key types and functions

NameKindRole
build_catalogfnFolds every compiled-in family’s get_all() into one ModuleCatalog, feature-gated per family (catalog.rs)
DEFAULT_MATCHERconst"expr", behind feature expr. The name build_catalog registers for the matcher family; the binary’s config fallback uses it so an unset [engine].matcher selects the same module (catalog.rs)
Engine, EngineDeps, EngineConfig, EngineErrorre-exportThe same types blockwatcher-core publishes; Engine::start takes EngineDeps by value
ControlHandlere-exportThe one path a running engine’s resources change through

Feature-flag wiring

Cargo.toml’s optional features each gate one dep: entry, all on by default:

[features]
default = ["evm", "expr", "sinks"]
evm = ["dep:blockwatcher-evm"]
expr = ["dep:blockwatcher-expr"]
sinks = ["dep:blockwatcher-sinks"]

build_catalog (catalog.rs) is where the effect of each flag is entirely mechanical: a #[cfg(feature = "...")] block around one fold, family by family:

FeatureOff, build_catalog no longer registers
evmblockwatcher_evm::sources::get_all() (evm-rpc, evm-mempool) and blockwatcher_evm::decoders::get_all() (evm)
exprblockwatcher_expr::matchers::get_all() (expr)
sinksblockwatcher_sinks::registry::sinks::get_all() (webhook, script, log)

Storage’s own registration, blockwatcher_storage::registry::storages::get_all() (memory, sqlite), is not behind any feature: blockwatcher-storage is a plain, non-optional dependency. Gate modules, blockwatcher_gates::registry::gates::get_all() (threshold, max_once), are the same: always registered.

A build missing a feature does not merely fail to offer the module by name; the factory function itself is absent, since the whole fold is compiled out. A config naming a module a build didn’t link refuses at catalog lookup time with the alternatives that build actually carries (catalog.rs’s own tests, an_unknown_storage_module_is_refused_naming_what_this_build_carries).

Neighbours

blockwatcher-embed depends on, in production:

  • blockwatcher-core
  • blockwatcher-storage
  • blockwatcher-gates
  • blockwatcher-expr (feature expr, on by default)
  • blockwatcher-evm (feature evm, on by default)
  • blockwatcher-sinks (feature sinks, on by default)

and, in [dev-dependencies] only:

  • tokio (macros/rt features): the catalog construction tests

The following crate depends on it directly (per the dependency table):

  • blockwatcher (binary): calls build_catalog at boot, seed validation, and checkpoint prune; forwards its own feature flags onto this crate’s matching features.

Reading the source

  1. lib.rs: the crate doc comment (why hosts depend here rather than assembling a catalog themselves) and the re-export surface.
  2. catalog.rs: build_catalog, and read it beside Feature-flag wiring above.

blockwatcher (binary)

blockwatcher is the composition root: it wires the engine, the module catalog, storage, the REST control plane, and the metrics exporter together, and implements none of them itself. Catalog construction lives in blockwatcher-embed; this crate calls build_catalog there so registration has one home. Every other rule it appears to enforce (what a resource must look like, what makes a monitor compilable) actually lives in blockwatcher-core or in a module crate; this crate only decides when each of those pieces gets constructed, in what order, and what the process does when asked to stop (lib.rs). It ships as a library target plus a thin binary, so the crate’s own integration tests can drive a boot in-process rather than only through a spawned process (lib.rs), and it is Unix-only in the sense that its one stop path is SIGTERM/SIGINT: a build for a target without those signals would have no way to ask it to stop, so it does not compile instead of compiling into a monitor nothing can shut down (lib.rs).

Its production dependencies are exactly the ALLOW_BLOCKWATCHER entry scripts/check-dep-graph.sh lists (crates/blockwatcher/Cargo.toml):

  • blockwatcher-types, blockwatcher-ports, blockwatcher-core: the vocabulary and the engine this crate boots
  • blockwatcher-embed: build_catalog and the same engine re-exports a host uses; this crate’s evm / expr / sinks features also enable the matching blockwatcher-embed features
  • blockwatcher-api: the REST control plane, served once the engine exists
  • blockwatcher-metrics: the Prometheus recorder and scrape endpoint, installed and served independently of the API
  • blockwatcher-storage: the only storage modules (memory, sqlite); a plain, non-optional dependency, so both are always present regardless of feature flags. Gate modules are registered the same way sinks are, through embed’s catalog fold (blockwatcher-gates is always-on, not a binary feature). Module families (expr, evm, sinks) are not direct dependencies: this crate’s feature flags forward onto blockwatcher-embed (see Feature-flag wiring below)
  • serde, serde_json: InstanceConfig and every resource shape a seed file or check deserializes
  • thiserror: the derive behind BootError, ConfigError, SeedError
  • toml: parsing an instance configuration file
  • figment: layering the TOML file with BLOCKWATCHER_* environment overrides
  • tokio (rt-multi-thread/macros/signal/net features): the async runtime main.rs builds, and the stop-signal plumbing run.rs installs
  • tokio-util: CancellationToken, the listener shutdown signal Running holds
  • tracing, tracing-subscriber (env-filter feature): init_tracing, which sends every diagnostic to stderr so stdout stays reserved for a running monitor’s own match output

Embed is the crate that links module families; this crate’s evm / expr / sinks features forward onto embed rather than declaring those crates. The dependency gate still exempts the binary for the families embed pulls in, because they appear in its transitive tree. Workspace map § Glue and test is where this crate’s role in the three rings is described, and Architecture decisions § Dependencies always point toward the vocabulary, never toward the engine is the rule that keeps every module crate usable from some other binary entirely: none of them may depend back on this one, or on blockwatcher-core.

Installation and building and Configuration reference already cover this crate from an operator’s side: how to build it, what each feature flag removes, what check’s exit codes mean, and every InstanceConfig key. This page does not restate any of that; it covers the boot order verified against run.rs’s actual source, the drain mechanics behind the exit codes, and which blockwatcher-embed catalog registrations a feature flag removes.

Key takeaways

  • blockwatcher is the composition root: it wires the engine, the module catalog, storage, the REST control plane, and the metrics exporter together, and implements none of them itself. Catalog construction is blockwatcher-embed::build_catalog; this crate’s feature flags forward onto embed rather than declaring the module crates. Nothing in the workspace depends on it back.
  • Every rule it appears to enforce actually lives in blockwatcher-core or a module crate; this crate only decides when each piece gets constructed, in what order, and what happens when asked to stop.
  • It ships as a library plus a thin binary, and is Unix-only in the sense that its one stop path is SIGTERM/SIGINT.

Responsibilities

  • Parses the command line by hand (cli.rs) into one of Command’s Run, Check, PruneCheckpoints, Help, or Version variants, without reading a file or constructing a module until a command actually runs (cli.rs).
  • Loads instance configuration once at boot, TOML layered with BLOCKWATCHER_* environment overrides, and never re-reads it for the life of the process (config.rs).
  • Boots in the order Startup sequence below verifies: resolves everything that can refuse without a side effect first, constructs storage, claims both listen ports, seeds only an empty store, and starts the engine last (run.rs::boot, run.rs).
  • Registers no modules of its own: blockwatcher_embed::build_catalog folds every compiled-in family, feature-gated so what a build can select is exactly what it linked (run.rs, crates/blockwatcher-embed/src/catalog.rs).
  • Validates a seed directory offline, against the exact modules this binary compiled in, without starting a pipeline or opening a listener (check.rs).
  • Owns the process’s stop signal and the shutdown drain that follows it, mapping the result onto the process exit code (run.rs::run, run.rs).
  • Sweeps orphaned checkpoint rows whose network resource no longer exists, offline, outside the running process (prune.rs).

Not this crate’s job: deciding whether a resource is valid, compiling a predicate, or running a pipeline (blockwatcher-core, called through Engine::start/Engine::validate and ControlHandle); implementing any module a config can select by name (each lives in its own module crate, gated behind this crate’s own feature flags); serving HTTP or Prometheus (blockwatcher-api::serve and blockwatcher_metrics::serve, both handed an already-bound listener by this crate and otherwise left alone, run.rs).

Key types and functions

NameKindRole
CommandenumWhat the command line asked for: Run { config, seed }, Check { dir }, PruneCheckpoints { config, dry_run }, Help, Version (cli.rs)
parsefnHand-rolled argument parser; refuses trailing arguments after a terminal command rather than ignoring them (cli.rs)
InstanceConfig, ApiSection, MetricsSection, EngineSectionstructThe whole instance configuration, every section defaulting so an empty file is valid (config.rs)
loadfnBuilds InstanceConfig from an optional TOML file plus BLOCKWATCHER_* overrides; refuses when neither is present (config.rs)
build_catalogfn (in blockwatcher-embed)Folds every compiled-in module family’s get_all() into one ModuleCatalog; this crate calls blockwatcher_embed::build_catalog (run.rs, seed.rs, prune.rs)
boot, Running, BootErrorfn / struct / enumThe whole startup sequence, the booted deployment handed back to a caller, and every way boot can refuse (run.rs)
run, exit_codeasync fn / fnThe whole process lifecycle (install stop handlers, boot, wait, drain) and the drain-report-to-exit-code mapping (run.rs)
StopSignalsstructUnix SIGTERM/SIGINT (Windows ctrl_c/ctrl_close/ctrl_shutdown), installed once before boot so a signal arriving mid-boot is not lost (run.rs)
SeedBundle, load, validate, persist_or_clearstruct / fnA seed directory’s resources, read whole, proved to construct through the engine’s own boot validation, then written once (seed.rs)
check, Summaryasync fn / structThe offline seed-directory check and what a passing run prints (check.rs)

Startup sequence

boot (run.rs) is the single function every Run command goes through, and its actual order, read straight from the source rather than assumed, is:

  1. Resolve everything that can refuse without a side effect. If [api].enabled, parse [api].listen and resolve [auth] (config.api_listen()/config.auth()); if [metrics].enabled, parse [metrics].listen; resolve the engine config, including the compiled-in matcher fallback (run.rs).
  2. Build the module catalog (blockwatcher_embed::build_catalog), folding every compiled-in family’s factories (run.rs).
  3. Construct storage, via the catalog’s factory for [storage].module, inline in boot; warn if the resolved module is memory, since nothing survives that backend across a restart (run.rs).
  4. Bind both listen ports via bind_listener, metrics before API, installing the Prometheus recorder via install_recorder in the same step as the metrics bind (run.rs).
  5. Apply the seed, only if --seed <dir> was given and only into a store still holding zero resources of every kind, checked inline in boot (run.rs; the emptiness check and the load/validate/persist steps are apply_seed, run.rs).
  6. Start the engine, Engine::start(EngineDeps { storage, catalog, config }), then build the one ControlHandle onto it (run.rs).
  7. Spawn the listener tasks, serve_metrics/serve_api, now that the engine and control handle exist for them to serve (run.rs).
flowchart TD
    a["1: resolve api/metrics addr + token,<br/>resolve engine config,<br/>inline in boot()<br/>run.rs"] --> b["2: blockwatcher_embed::build_catalog()<br/>blockwatcher-embed/src/catalog.rs"]
    b --> c["3: construct storage<br/>via catalog's factory,<br/>inline in boot()<br/>run.rs"]
    c --> d["4: bind metrics listener<br/>+ install_recorder<br/>run.rs"]
    d --> e["4: bind api listener<br/>via bind_listener<br/>run.rs"]
    e --> f{"seed dir given?"}
    f -->|"yes, and store<br/>holds zero resources"| g["5: apply_seed:<br/>load, validate, persist<br/>run.rs"]
    f -->|"no, or store<br/>already non-empty"| h["6: Engine::start(EngineDeps)<br/>run.rs"]
    g --> h
    h --> i["ControlHandle::new(engine)<br/>run.rs"]
    i --> j["7: spawn listener tasks<br/>serve_metrics / serve_api<br/>run.rs"]

Two orderings here are easy to get backwards from memory, and both are deliberate, per boot’s own doc comment (run.rs):

  • The catalog is built, and storage constructed, before either listener is bound, but both listeners are bound before the seed is applied. Claiming a port is one of the last things that can refuse without having written anything an operator has to undo; seeding is the first thing that writes. A typoed listen address must be discovered before a store holding any resource stops being eligible for seeding, not after.
  • The engine starts last. Starting it runs real sources and delivers real events to real sinks. Everything above it, including a metrics recorder that refuses to install on a cold process, must have already refused if it was going to, so a boot that reports failure has sent nothing anywhere.

The check command

blockwatcher check <dir> (check.rs) is a wholly separate path from boot: it never reads an instance config file, never opens a real listener, and never touches real storage. check (check.rs) calls validate (check.rs), which:

  1. Loads the seed directory (seed::load), the same all-or-nothing read apply_seed uses.
  2. Resolves the engine config from InstanceConfig::default() rather than any real file, so the matcher this check validates against is the one this build compiled in, and the refusal a build with none produces is identical to what a real boot would say.
  3. Runs seed::validate, the exact function apply_seed calls: it builds a real ModuleCatalog, constructs a scratch in-memory store, persists the bundle into it, and runs the whole bundle through Engine::validate (the same construction and compilation path Engine::start runs, minus actually spawning a pipeline).

A pass prints Summary’s Display to stdout (check.rs:55-63) and returns 0:

ok: 3 networks, 5 specs, 2 sinks, 8 monitors

A failure prints the refusal to stderr and returns 1 (check.rs). Because validation constructs every module, a sink config that resolves a secret through env:NAME needs that variable present in check’s own environment, exactly as a real boot would need it. check never returns 2 or 64: those two codes belong respectively to a drain that aborted at the deadline and to a command line this binary could not parse, and check goes through neither the drain path nor the general argument parser once its own subcommand name has matched inside parse (cli.rs).

Feature-flag wiring

Cargo.toml’s optional features each forward onto the matching blockwatcher-embed feature, all on by default. This crate does not declare the module crates itself; embed is the one composition façade that links them:

[features]
default = ["evm", "expr", "sinks"]
evm = ["blockwatcher-embed/evm"]
expr = ["blockwatcher-embed/expr"]
sinks = ["blockwatcher-embed/sinks"]

blockwatcher_embed::build_catalog (crates/blockwatcher-embed/src/catalog.rs) is where the effect of each flag is entirely mechanical: a #[cfg(feature = "...")] block around one fold, feature by feature, over the source, decoder, matcher, and sink families. This crate’s flags also enable the matching blockwatcher-embed features so the catalog this binary links is the catalog embed would build with the same flags:

FeatureOff, build_catalog no longer registersOff, elsewhere
evmblockwatcher_evm::sources::get_all() (evm-rpc, evm-mempool) and blockwatcher_evm::decoders::get_all() (evm) (catalog.rs in blockwatcher-embed)none
exprblockwatcher_expr::matchers::get_all() (expr) (catalog.rs in blockwatcher-embed)config::default_matcher (config.rs) has no fallback module to offer, so an unset [engine].matcher refuses boot as ConfigError::NoMatcher rather than substituting anything
sinksblockwatcher_sinks::registry::sinks::get_all() (webhook, script, log) (catalog.rs in blockwatcher-embed)none

Storage’s own registration, blockwatcher_storage::registry::storages::get_all() (memory, sqlite), is not behind any feature at all: blockwatcher-storage is a plain, non-optional dependency of embed (catalog.rs in blockwatcher-embed), so no flag ever removes it.

A build missing a feature does not merely fail to offer the module by name; the factory function itself is absent from the binary, since the whole fold is compiled out. A config naming a module a build didn’t link refuses at catalog lookup time with the alternatives that build actually carries, never with the module it lacks silently treated as unavailable without saying so (blockwatcher-embed’s own tests, an_unknown_storage_module_is_refused_naming_what_this_build_carries, assert exactly this against the real catalog).

Shutdown and drain

run (run.rs) installs StopSignals (Unix SIGTERM/SIGINT, or the three Windows equivalents) before calling boot, specifically so a signal arriving during a slow first boot (seeding, module construction) is answered here rather than escalated to a kill by whatever supervisor sent it (run.rs). A tokio::select! races the signal against boot itself, biased so a boot that has already finished always wins over a signal that happened to arrive in the same instant: a completed boot always gets a real drain rather than being torn down mid-construction (run.rs). A signal that wins the race (boot still in flight) exits 0 immediately, with a warning that a store may hold part of an interrupted seed and that a store holding any resource is never seeded again (run.rs).

Once boot succeeds, run waits for the next stop signal, then calls Running::shutdown (run.rs), which:

  1. Calls Engine::shutdown(). This is blockwatcher-core’s own drain: every pipeline’s drain_pipeline (crates/blockwatcher-core/src/engine/drain.rs) awaits its tasks up to EngineConfig::drain_deadline_ms (default 10_000), escalating to hard_cancel plus a 500ms grace on timeout, then aborting any straggler; see blockwatcher-core § Boot, restart, and shutdown for that ladder in full. The result is a ShutdownReport { drained, aborted }.
  2. Cancels listener_shutdown (a CancellationToken), which both serve_metrics and serve_api are watching, telling each axum::serve call to stop accepting new connections and let whatever it is already answering finish (run.rs).
  3. Waits for each listener task, for at most listener_grace. This is Duration::from_millis(engine_config.drain_deadline_ms), computed once at boot (run.rs) and reused rather than given its own separate number: a request left half-answered must not be able to extend the shutdown any further than a wedged sink already could. A listener that does not finish inside its grace is abandoned (the handle is dropped, not aborted, so an in-flight response gets whatever time the process exit leaves it) with a tracing::warn! naming which listener (run.rs).

exit_code (run.rs) maps the ShutdownReport alone onto the process exit code: 0 if aborted is empty, 2 otherwise. Config, boot, and seed failures short-circuit earlier and exit 1; a command line parse could not make sense of exits 64 (cli.rs). The full table:

CodeReached from
0exit_code on a clean drain, or a stop signal that arrived before boot finished, or check/prune-checkpoints succeeding
1config::load, boot, or a seed failure printed and returned early (run.rs); check/prune-checkpoints refusing
2exit_code when ShutdownReport.aborted is non-empty: the drain deadline forced at least one pipeline’s abort
64cli::parse rejecting the command line (USAGE_EXIT, cli.rs)

prune-checkpoints (prune.rs) is offline and outside this lifecycle entirely: it loads instance config, opens storage the same way boot does, sweeps or lists (--dry-run) orphaned checkpoint rows, and returns only 0 or 1 (prune.rs); it never starts an engine, never binds a listener, and never goes through the drain above.

Neighbours

blockwatcher depends on, in production (the same list named in full above):

  • blockwatcher-types
  • blockwatcher-ports
  • blockwatcher-core
  • blockwatcher-embed
  • blockwatcher-api
  • blockwatcher-metrics
  • blockwatcher-storage
  • serde, serde_json
  • thiserror
  • toml
  • figment
  • tokio (rt-multi-thread/macros/signal/net features)
  • tokio-util
  • tracing, tracing-subscriber (env-filter feature)

and, in [dev-dependencies] only:

  • blockwatcher-testkit: shared test scaffolding
  • blockwatcher-ports (fakes feature): the in-memory fakes a boot test constructs against instead of a real module
  • blockwatcher-evm-testkit: a real Pool<EvmEndpoint> for tests that need the evm feature’s real construction path rather than a fake
  • tempfile: scratch directories for seed and config tests
  • tokio (macros/rt-multi-thread/time/test-util features): paused-time and multi-threaded async tests
  • figment (test feature): the env/fs jail config.rs’s tests run inside

Nothing in the workspace depends on blockwatcher: it is the composition root, the end of every dependency chain rather than the start of one (per the dependency table, where its row is the longest but has no crate pointing back at it).

Reading the source

  1. lib.rs: the module doc comment (why the binary is Unix-only, why a library target sits beside it) and init_tracing.
  2. cli.rs: Command, parse, and USAGE: the whole surface a user types against, before any file is read or any module constructed.
  3. config.rs: InstanceConfig and its sections, load, and engine_config’s matcher-fallback logic (including journal_depth).
  4. seed.rs: SeedBundle, load, validate, persist_or_clear: read this before run.rs::apply_seed, which is a thin caller over exactly these functions. Catalog construction is blockwatcher_embed::build_catalog.
  5. run.rs: boot, Running, and run, in that order; read boot’s own doc comment (run.rs) beside Startup sequence above.
  6. check.rs: check and validate, both short, and both callers of seed.rs functions already covered above.
  7. prune.rs: prune_checkpoints and sweep, the one command that never touches the engine at all.
  8. main.rs: the actual entry point, and how little of the crate’s real decision-making happens here.

blockwatcher-testkit

blockwatcher-testkit is shared test scaffolding: a recording metrics recorder and a handful of small harnesses that let a module’s tests drive a port the same way every other module’s tests do, instead of each crate growing its own copy of “wait for this condition” or “assert the storage contract holds.” Its whole lib.rs doc comment states the constraint the rest of this page is built on: “Dev-dependency only — the dependency gate rejects any production edge onto this crate by name” (crates/blockwatcher-testkit/src/lib.rs:1-2). See Workspace map § Verifying the rings for exactly how scripts/check-dep-graph.sh checks that by name rather than folding it into the ordinary allowlist rule.

Its production dependencies:

  • blockwatcher-types: RawEvent (source_run.rs) and the resource/checkpoint/ dead-letter vocabulary storage_contract.rs writes and reads
  • blockwatcher-ports: Storage/StorageError (storage_contract.rs) and SourceError (source_run.rs), the port traits this crate’s harnesses drive
  • metrics: the Recorder/Counter/Gauge/Key types recorder.rs implements against
  • serde_json: the JSON values storage_contract.rs’s exercise writes and compares
  • tokio (time/rt features): the timers wait.rs and source_run.rs await on
  • tokio-util: CancellationToken, the type source_run.rs’s stop cancels and asserts the Source port’s cancellation contract against

No [dev-dependencies] section exists in crates/blockwatcher-testkit/Cargo.toml at all: this crate’s own unit-tested modules (endpoint_url.rs, recorder.rs) exercise themselves with nothing beyond what production already pulls in.

Key takeaways

  • blockwatcher-testkit is shared test scaffolding: a recording metrics recorder and small harnesses that let a module’s tests drive a port the same way every other module’s tests do.
  • It is dev-dependency only; the dependency gate rejects any production edge onto this crate by name.
  • Its harnesses take a real or fake port implementation as an argument and drive it; they never substitute for a port directly.

Responsibilities

Small files, each owning exactly one harness:

  • endpoint_url.rs: publish_endpoint_url, which writes a URL into a freshly claimed environment variable and hands back the env:NAME reference an evm-rpc endpoint’s url_secret expects, so a test can point a source at a mock node without writing that node’s ephemeral URL into seed JSON.
  • recorder.rs: RecordingMetrics, a metrics::Recorder that records every counter emission and every counter/gauge registration instead of exporting them, so a test can assert an emission fired with the labels it claims, or that a module registered the metrics it documents even when a code path never drove them.
  • source_run.rs: recv and stop, for driving a running Source from a test: taking its next event with a bounded wait, and cancelling it while asserting the port’s cancellation contract (a cancelled run returns promptly and returns Ok(()), never an error, never a panic).
  • storage_contract.rs: exercise_storage_contract, the one behavioral contract every Storage implementation in the workspace, the ports fake included, must pass, plus dead_letter, a fixture builder for the DeadLetter value that exercise and any other test needs.
  • wait.rs: until, the bounded poll every suite that waits on a condition shares, so a broken property fails its own test rather than hanging the suite.

Not this crate’s job: implementing a real module, deciding what a port’s contract means (blockwatcher-ports defines the traits and error enums this crate’s harnesses drive; this crate only exercises them), or substituting for a port directly. blockwatcher-portsfakes feature (FakeSource, FakeDecoder, FakeMatcher, FakeSink, MemoryStorage, FlakyStorage) and testing feature (mockall-generated mocks) are the in-memory implementations and unit-level substitutes a test builds against; see blockwatcher-ports § Responsibilities. This crate’s own harnesses take a real or fake port implementation as an argument or a type parameter and drive it, rather than being one.

Key types and functions

NameKindRole
publish_endpoint_urlfnClaims a fresh, unique environment variable, writes url into it, and returns the env:NAME reference (endpoint_url.rs)
RecordingMetricsstructA metrics::Recorder recording every counter increment/absolute and every counter/gauge registration, queryable via count/registrations (recorder.rs)
recvasync fnThe next event a running Source emits, or a panic past a 10-second deadline (source_run.rs)
stopasync fnCancels a running Source and asserts it returns promptly with Ok(()) (source_run.rs)
exercise_storage_contractasync fnRuns every section of the Storage port’s behavioral contract against a fresh instance the caller constructs per section (storage_contract.rs)
dead_letterfnA DeadLetter fixture for a synthetic Transfer event at a given block (storage_contract.rs)
untilasync fnPolls an async condition every 10ms up to a 60-second deadline, panicking with a caller-supplied subject on timeout (wait.rs)

The metrics recorder

RecordingMetrics installs thread-locally via metrics::set_default_local_recorder, which is why its own doc comment warns that tests using it must run on a current-thread tokio runtime: “the default #[tokio::test] flavor” (recorder.rs:1-9). An emission from a worker thread of a multi-thread runtime never reaches it. Its Recorder impl (recorder.rs:94-124) is deliberately asymmetric across the three metric kinds metrics defines:

#![allow(unused)]
fn main() {
impl Recorder for RecordingMetrics {
    fn describe_counter(&self, _: KeyName, _: Option<Unit>, _: SharedString) {}
    fn describe_gauge(&self, _: KeyName, _: Option<Unit>, _: SharedString) {}
    fn describe_histogram(&self, _: KeyName, _: Option<Unit>, _: SharedString) {}

    fn register_counter(&self, key: &Key, _: &Metadata<'_>) -> Counter {
        let name = key.name().to_string();
        let labels = labels_of(key);
        let mut state = self.state.lock().expect("recording metrics lock");
        state.registrations.push((name.clone(), labels.clone()));
        drop(state);
        Counter::from_arc(Arc::new(CounterHandle {
            name,
            labels,
            state: Arc::clone(&self.state),
        }))
    }

    fn register_gauge(&self, key: &Key, _: &Metadata<'_>) -> Gauge {
        self.state
            .lock()
            .expect("recording metrics lock")
            .registrations
            .push((key.name().to_string(), labels_of(key)));
        Gauge::noop()
    }

    fn register_histogram(&self, _: &Key, _: &Metadata<'_>) -> Histogram {
        Histogram::noop()
    }
}
}

Only a counter’s handle actually records a value (CounterHandle::increment/absolute, recorder.rs): a gauge’s registration is captured, but every gauge handle this recorder ever hands back is Gauge::noop(), and every histogram (registration included) is dropped entirely. count’s own doc comment names the one caveat that follows from folding absolute into increment: “mixing increment and absolute on one counter yields a sum, not the counter’s true value — this double aggregates every event, it does not track counter semantics” (recorder.rs:46-51).

blockwatcher-core (pipeline/mod.rs, tests/engine/restart.rs), blockwatcher-rpc (src/pool.rs), blockwatcher-sinks (tests/sinks/engine_delivery.rs), and blockwatcher-evm (tests/mempool_loop.rs, tests/reorg_and_failover.rs) each import RecordingMetrics to assert on what their own pipeline or pool emits through the metrics facade, in place of a real Prometheus backend.

Driving a source: recv and stop

Both functions in source_run.rs exist because the two obvious alternatives (a bare recv().await and a bare abort()) can hang a whole suite instead of failing one test. recv wraps the receiver in a 10-second tokio::time::timeout (source_run.rs); stop cancels the supplied token, then asserts, within the same deadline, that the Source’s run future returns and returns exactly Ok(()) (source_run.rs:31-41):

#![allow(unused)]
fn main() {
pub async fn stop(
    cancel: CancellationToken,
    handle: tokio::task::JoinHandle<Result<(), SourceError>>,
) {
    cancel.cancel();
    tokio::time::timeout(DEADLINE, handle)
        .await
        .expect("run did not return promptly after cancel")
        .expect("run task panicked")
        .expect("run returned an error instead of Ok(()) after cancel");
}
}

blockwatcher-evm’s mempool_loop.rs, reorg_and_failover.rs, and source_loop.rs all import both functions to drive evm-rpc and evm-mempool sources against a mock node.

The storage contract

exercise_storage_contract takes a factory (Fn() -> Future<Output = Arc<dyn Storage>>) rather than one instance, and calls it once per section (storage_contract.rs), because its own module doc comment states the precondition every section depends on: “fresh must return an empty store on every call” (storage_contract.rs:1-3). The sections it runs, in order, cover optimistic-concurrency create/update/delete and its rejection paths, ResourceKind namespacing and sorted listing, per-network checkpoint round-tripping, dead-letter insertion-order paging, and, its most pointed section, values and cursors that a typed SQL column could not hold at all: a u64 above 2^53, non-ASCII text, a nested JSON null, and a checkpoint cursor at u64::MAX, “bigger than i64::MAX, the ceiling of SQLite’s signed 64-bit INTEGER type” (storage_contract.rs:317-324). blockwatcher-storage’s tests/contract.rs runs this same exercise against the ports fake, its own in-memory module, and its sqlite module in turn, so all three are proven equivalent by a shared test rather than by shared code; see blockwatcher-storage § Neighbours for that crate’s side of the same import.

Bounded waiting: until

wait.rs’s own doc comment states the shared budget: a 10ms poll interval against a 60-second deadline, “generous, because it covers a loaded machine that may be compiling at the same time” (wait.rs:1-13). The wait between polls is a real tokio::time::sleep, never a yield_now spin, which is what lets a #[tokio::test(start_paused = true)] test’s virtual clock auto-advance past whatever the condition is waiting on (wait.rs). blockwatcher-api’s tests/api/helpers.rs, blockwatcher-core’s tests/engine/helpers.rs, and the blockwatcher binary’s tests/boot.rs and tests/escalation_seam.rs each wrap this same until behind their own crate-local convenience function rather than calling it inline at every call site.

blockwatcher-e2e needs the identical shape but cannot depend on this crate at all (its own allowlist entry is empty, see blockwatcher-e2e), so its tests/e2e/harness.rs hand-writes a second until rather than importing this one, quoting a captured process’s stderr into its panic message the way a black-box scenario needs to. publish_endpoint_url is duplicated the same way, for the same reason, in tests/e2e/harness.rs’s write_chain_seed (crates/blockwatcher-e2e/tests/e2e/harness.rs, doc comment).

Neighbours

Every consumer reaches for a different subset of this crate’s harnesses:

flowchart LR
    testkit["blockwatcher-testkit"] --> core["blockwatcher-core<br/>RecordingMetrics, until"]
    testkit --> api["blockwatcher-api<br/>until"]
    testkit --> rpc["blockwatcher-rpc<br/>RecordingMetrics"]
    testkit --> sinks["blockwatcher-sinks<br/>RecordingMetrics"]
    testkit --> storage["blockwatcher-storage<br/>exercise_storage_contract"]
    testkit --> evm["blockwatcher-evm<br/>RecordingMetrics, recv, stop"]
    testkit --> bin["blockwatcher binary<br/>publish_endpoint_url, until"]

blockwatcher-testkit depends on, in production:

  • blockwatcher-types
  • blockwatcher-ports
  • metrics
  • serde_json
  • tokio (time/rt features)
  • tokio-util

The following crates depend on it, every edge under [dev-dependencies] only (per the dependency table, and checked by name rather than by the general allowlist rule, per Workspace map § Verifying the rings):

  • blockwatcher-core: RecordingMetrics (pipeline/mod.rs’s own tests, tests/engine/restart.rs) and until (tests/engine/helpers.rs)
  • blockwatcher-api: until (tests/api/helpers.rs)
  • blockwatcher-rpc: RecordingMetrics (src/pool.rs’s own tests)
  • blockwatcher-sinks: RecordingMetrics (tests/sinks/engine_delivery.rs)
  • blockwatcher-storage: dead_letter and exercise_storage_contract (tests/contract.rs)
  • blockwatcher-evm: RecordingMetrics, recv, stop (tests/mempool_loop.rs, tests/reorg_and_failover.rs, tests/source_loop.rs)
  • blockwatcher (binary): publish_endpoint_url and until (tests/boot.rs, tests/escalation_seam.rs)

Reading the source

  1. lib.rs: the module list, the crate’s whole re-export surface (one name or pair per module), and the doc comment stating the dev-dependency-only constraint the gate enforces by name.
  2. wait.rs: until, the smallest file and the one every other harness’s own tests lean on indirectly through the pattern it establishes.
  3. source_run.rs: recv, then stop; read the module doc comment first, since both functions exist to turn a possible hang into a bounded failure.
  4. storage_contract.rs: exercise_storage_contract’s section calls, then each section function in the order it calls them; dead_letter at the bottom of the file is the one fixture builder the sections above it also share.
  5. recorder.rs: RecordingMetrics, CounterHandle, and the Recorder impl; the module doc comment states the thread-local caveat before anything else.
  6. endpoint_url.rs: publish_endpoint_url; short enough to read in one pass, and its doc comment is also the fullest explanation in the crate of why an ephemeral mock node’s URL has to travel through the environment rather than through seed JSON.

blockwatcher-evm-testkit

blockwatcher-evm-testkit is a scripted JSON-RPC and WebSocket mock node, an in-memory chain to serve it from, and the cursor-packing pin and pool builder blockwatcher-evm’s own tests share, so that no test in that crate, or in the blockwatcher binary, or in blockwatcher-e2e’s in-process scenarios has to run against a real Ethereum client. Its own lib.rs doc comment states the shape this crate’s dependency direction takes: “Consumed exclusively via [dev-dependencies]: nothing here ever reaches a production binary. It depends on blockwatcher-evm while blockwatcher-evm dev-depends on it — a cycle cargo permits precisely because the edge back is a dev edge” (crates/blockwatcher-evm-testkit/src/lib.rs:4-8). See Workspace map § Verifying the rings for how scripts/check-dep-graph.sh checks the dev-dependency-only half of that by name, the same way it checks blockwatcher-testkit.

The harness and the system under test line up like this: a scripted node on one side, a real evm-rpc or evm-mempool source on the other, meeting over the same wire protocol a real node would speak:

flowchart LR
    simchain["SimChain<br/>in-memory chain"] --> mocknode["mock_node<br/>scripted json-rpc"]
    mockws["MockWsNode<br/>scripted directives"] --> wsnode["mock_ws_node<br/>scripted eth_subscribe"]
    mocknode --> pool["single_endpoint_pool<br/>real Pool of EvmEndpoint"]
    pool --> src1["evm-rpc source<br/>blockwatcher-evm"]
    wsnode --> src2["evm-mempool source<br/>blockwatcher-evm"]
    src1 --> test["test asserts on<br/>decoded events, cursors"]
    src2 --> test

Its production dependencies:

  • blockwatcher-types: the Cursor type cursors.rs’s pin constructs
  • blockwatcher-evm: EvmEndpoint, the type pool.rs’s builders parameterize a real Pool with, and the reason this crate depends on blockwatcher-evm rather than the other way around
  • blockwatcher-rpc: Pool, PoolConfig, EndpointConfig, Priority, the connection-pool types single_endpoint_pool constructs a real instance of
  • axum (ws feature): the HTTP server both mock_node and mock_ws_node bind, and the WebSocket upgrade ws_mock.rs speaks
  • alloy-primitives: keccak256, the hash function every block and transaction hash in sim_chain.rs derives from
  • reqwest: the HTTP client pool.rs’s evm_endpoint configures
  • serde_json: the JSON-RPC request/response shapes every module in this crate builds
  • tokio (net/rt-multi-thread/sync/time features): the listener and the broadcast channel behind MockWsNode’s directives run on
  • url: parsing every node’s bound address

Key takeaways

  • blockwatcher-evm-testkit is a scripted JSON-RPC and WebSocket mock node, an in-memory chain, and the cursor-packing pin blockwatcher-evm’s own tests share, so no test needs a real Ethereum client.
  • It is consumed exclusively via [dev-dependencies]; it depends on blockwatcher-evm while blockwatcher-evm dev-depends back on it, a cycle cargo permits because the edge back is dev-only.
  • A scripted node stands in for a real node on one side, and a real evm-rpc or evm-mempool source runs unmodified on the other, meeting over the same wire protocol.

Responsibilities

  • Runs a scripted JSON-RPC node on an ephemeral loopback port: mock_node takes a handler closure over (method, params) and answers whatever MockReply it returns, so a test swaps behavior by the closure it passes rather than by editing this crate (lib.rs).
  • Runs a scripted eth_subscribe WebSocket node the same way: mock_ws_node answers any first message of a connection as a successful subscription, then forwards whatever a test pushes through MockWsNode as eth_subscription notifications (ws_mock.rs).
  • Provides SimChain, a mutable in-memory chain a mock_node handler can serve: deterministic, salt-derived block and transaction hashes, and reorg_from/reorg_to for producing a fork mid-test (sim_chain.rs).
  • Provides single_endpoint_pool and evm_endpoint, so a test that needs a real Pool<EvmEndpoint> pointed at a mock node builds one in one call instead of assembling PoolConfig/EndpointConfig/Priority itself (pool.rs).
  • Provides log_cursor and tx_cursor, a hand-mirrored copy of the evm-rpc source’s cursor-packing formula, kept deliberately separate from the production pack_secondary function it mirrors (cursors.rs).
  • Provides CallLog, which records every JSON-RPC call a mock node’s handler observed, method and params rather than just method, so a test can assert not only call order but which range or block a specific call actually named (lib.rs).

Not this crate’s job: implementing evm-rpc or evm-mempool themselves (blockwatcher-evm owns both; this crate only mocks the wire they speak to), running anything against a real EVM node (blockwatcher-e2e‘s anvil-backed scenarios are the only place in the workspace that does; see blockwatcher-e2e), or standing in for a chain-agnostic port directly (blockwatcher-portsfakes feature does that; this crate mocks one family’s wire protocol underneath a real module, not the port trait above it).

Key types and functions

NameKindRole
mock_nodeasync fnStarts a scripted HTTP JSON-RPC node on an ephemeral port, returning once it accepts connections (lib.rs)
MockNodestructThe running node’s handle; url is where to point an endpoint, and dropping it aborts the listener task (lib.rs)
MockReplyenumOne scripted answer: Result, Error, Status, RawBody (a literal status and body, sent verbatim), or Hang (accepted, never answered) (lib.rs)
CallLogstructRecords every call a handler observed, method and params, in order (lib.rs)
SimChainstructA mutable chain of contiguous, gapless blocks; with_blocks, extend, reorg_from, reorg_to, add_log, add_tx, head, hash_of, and handler (sim_chain.rs)
mock_ws_nodeasync fnStarts a scripted WebSocket eth_subscribe node (ws_mock.rs)
MockWsNodestructThe running WS node’s handle: push_hash, push_raw, close_connection, go_silent, pings_received (ws_mock.rs)
SOLO_ENDPOINTconstThe name every single-endpoint pool this crate builds gives its one endpoint (pool.rs)
evm_endpointfnAn EvmEndpoint dialing a given URL with only a 2-second request timeout configured (pool.rs)
single_endpoint_poolfnA real Pool<EvmEndpoint> of exactly one high-priority, unlimited endpoint (pool.rs)
log_cursor, tx_cursorfnThe hand-mirrored cursor for an emitted log or transaction (cursors.rs)

The mock JSON-RPC node

mock_node’s handler sees each request’s method and params and returns a MockReply; handle_request (lib.rs:136-167) turns that into an HTTP response:

#![allow(unused)]
fn main() {
async fn handle_request(
    State(handler): State<Arc<RequestHandler>>,
    Json(body): Json<Value>,
) -> Response {
    let method = body
        .get("method")
        .and_then(Value::as_str)
        .unwrap_or_default();
    let id = body.get("id").cloned().unwrap_or(json!(1));
    let params = body.get("params").cloned().unwrap_or(Value::Null);

    match handler(method, &params) {
        MockReply::Result(result) => {
            Json(json!({"jsonrpc": "2.0", "id": id, "result": result})).into_response()
        }
        MockReply::Error { code, message } => Json(json!({
            "jsonrpc": "2.0",
            "id": id,
            "error": {"code": code, "message": message},
        }))
        .into_response(),
        MockReply::Status(status) => StatusCode::from_u16(status)
            .expect("test-provided status code is a valid HTTP status")
            .into_response(),
        MockReply::RawBody { status, body } => (
            StatusCode::from_u16(status).expect("test-provided status code is a valid HTTP status"),
            body,
        )
            .into_response(),
        MockReply::Hang => std::future::pending::<Response>().await,
    }
}
}

RawBody and Hang exist for exactly the shapes Result, Error, and Status cannot express: a body missing both result and error, one carrying both, a well-formed JSON-RPC error wrapped in a non-2xx status, and a node that accepts the connection and never answers at all, so the caller’s own timeout, not the mock server, is what ends that call.

SimChain: a mutable, reorg-capable chain

Every block’s hash is block_hash’s keccak256(number ++ salt) (sim_chain.rs), and every block’s parentHash is read from whatever the previous block’s hash happens to be at read time, never recomputed from the formula. That is what lets reorg_from and reorg_to change the salt for a suffix of the chain and get back an internally consistent set of hashes for exactly that suffix, while the shared prefix keeps its original hashes untouched (sim_chain.rs, module doc comment). reorg_from replaces every block from a given number up with a same-height, different-salt fork; reorg_to is the shape that can shorten or lengthen the chain instead, up to a target length (sim_chain.rs). SimChain::handler answers exactly the methods eth_blockNumber, eth_getBlockByNumber, eth_getLogs, and eth_getTransactionReceipt, and panics on any other, “since nothing in this family’s source implementation calls anything else” (sim_chain.rs:265-269).

SimChain’s own module doc comment lists the mock liberties a test must not lean on:

Deliberate mock liberties a test must not lean on: `SimChain`'s handler
answers `eth_getBlockByNumber` for numeric block tags only (a real
client's `"latest"`/`"finalized"` tags panic it); its
`eth_getLogs` enforces no range or result-size cap — range-limit behavior
is driven by a handler injecting explicit error replies — and matches
only topic position 0; logs carry no `removed` field; and a log's
`transactionHash` is its block's hash, not a real transaction's.
Transactions added via `SimChain::add_tx` carry a fixed zero `value` —
there is no parameter for it at all — and none of a real transaction's
other fields (`gas`, `nonce`, `type`, `chainId`, `gasPrice`/
`maxFeePerGas`, the `v`/`r`/`s` signature fields, `accessList`, and so
on); a test needing those must construct transaction JSON directly
rather than going through a chain. Receipts from
`eth_getTransactionReceipt` carry only `status` and positional fields
(`transactionHash`, `blockNumber`, `blockHash`) — no gas usage, logs,
`contractAddress`, or any of a real receipt's other fields.

(crates/blockwatcher-evm-testkit/src/lib.rs:21-36)

MockWsNode: a scripted mempool endpoint

ws_mock.rs exists for blockwatcher-evm’s mempool tests: its own module doc comment states the shape of its handshake: it “answers the first JSON request of every connection as a successful eth_subscribe, then forwards whatever hashes the test pushes as eth_subscription notifications” (ws_mock.rs:1-7). lib.rs’s own module doc comment names the liberties that handshake takes: the mock answers any first message this way, “without checking the method name or the subscribed event,” and the subscription id it hands back is always the literal "0x1", never a freshly generated one; only ever one subscription exists per connection at all, so eth_unsubscribe and a second eth_subscribe on the same socket are not handled, silently ignored the same as any other message once the first reply is sent (lib.rs:38-44). close_connection severs the current socket without stopping the listener, so the next dial gets a fresh subscription (ws_mock.rs); go_silent stops touching the socket at all: no reads, no writes, not even the automatic pong axum’s WebSocket would otherwise send, which leaves exactly the half-open shape a vanished peer leaves behind (ws_mock.rs). pings_received lets a test observe that a keepalive ping arrived without the mock ever having to answer it itself: axum auto-answers pings, so this counter only proves the idle clock never fired.

The cursor-packing pin

cursors.rs’s own module doc comment states why log_cursor and tx_cursor exist as hand-written functions rather than calls into production code: “a test asserting the wire contract must not verify the packer with itself, or a bug in pack_secondary and a matching bug here would cancel out and the test would still pass. Keep this hand-written, even though it is tempting to ‘simplify’ into a call to the real function — that temptation is exactly what would silence the pin” (cursors.rs:1-16).

#![allow(unused)]
fn main() {
/// The cursor an emitted log carries.
pub fn log_cursor(block: u64, log_index: u64) -> Cursor {
    Cursor::new(block, (1u64 << 32) | log_index)
}

/// The cursor an emitted transaction carries — see [`log_cursor`].
pub fn tx_cursor(block: u64, tx_index: u64) -> Cursor {
    Cursor::new(block, tx_index)
}
}

Both encode the same rule the evm-rpc source packs an occurrence’s kind into the secondary field’s high bits by: transactions are kind 0, logs are kind 1, so both kinds share one non-decreasing stream per block.

single_endpoint_pool: a real pool over a mock node

pool.rs’s own module doc comment explains why this helper lives in a library crate rather than in each test binary’s own common module: “a pub item in a private mod common of a test binary is dead code the moment one binary stops using it,” while “a pub item in a library crate is never dead code,” which is what lets every consuming suite share exactly the parts it needs (pool.rs:1-9). single_endpoint_pool builds a real blockwatcher_rpc::Pool<EvmEndpoint> of exactly one high-priority, unlimited endpoint named SOLO_ENDPOINT, pointed at a caller-supplied URL:

#![allow(unused)]
fn main() {
pub fn single_endpoint_pool(url: url::Url) -> Pool<EvmEndpoint> {
    Pool::new(
        PoolConfig::default(),
        vec![(
            EndpointConfig {
                name: SOLO_ENDPOINT.into(),
                priority: Priority::High,
                rate_limit: None,
            },
            evm_endpoint(url),
        )],
    )
    .expect("a single healthy endpoint always constructs a pool")
}
}

blockwatcher-evm’s tests/mempool_loop.rs and tests/common.rs import it directly; blockwatcher-evm’s src/source/rpc/scan.rs instead defines its own local, differently-shaped evm_endpoint/single_endpoint_pool pair for its own module tests rather than importing this one. This crate depends on blockwatcher-rpc directly (not only transitively through blockwatcher-evm), for exactly this function; see blockwatcher-rpc § Neighbours for that crate’s account of the same edge.

Neighbours

blockwatcher-evm-testkit depends on, in production:

  • blockwatcher-types
  • blockwatcher-evm
  • blockwatcher-rpc
  • axum (ws feature)
  • alloy-primitives
  • reqwest
  • serde_json
  • tokio (net/rt-multi-thread/sync/time features)
  • url

The following crates depend on it, every edge under [dev-dependencies] only (per the dependency table, and checked by name per Workspace map § Verifying the rings):

  • blockwatcher-evm: every one of MockNode, MockReply, CallLog, SimChain, mock_ws_node, log_cursor, tx_cursor, evm_endpoint, and single_endpoint_pool, imported inside #[cfg(test)] modules across registry.rs, source/mempool/pending.rs, source/rpc/{emit,fixtures,run,scan}.rs (fixtures.rs’s source_against takes a &MockNode directly), ws.rs, and the tests/*.rs integration suites (the dependency cycle this creates back onto blockwatcher-evm is exactly the dev-only edge this page’s introduction names)
  • blockwatcher (binary): a real Pool<EvmEndpoint> for tests that need the evm feature’s real construction path rather than a fake (tests/boot.rs, tests/escalation_seam.rs)
  • blockwatcher-e2e: MockNode, SimChain, mock_node directly, for its in-process scenarios (tests/e2e/shutdown.rs, tests/e2e/stdout_purity.rs) that need a scripted chain but not a real one; see blockwatcher-e2e § Neighbours for the contrast with that crate’s anvil-backed scenarios, which use a real node instead

Reading the source

  1. lib.rs’s module doc comment: the dependency-cycle rationale, the ephemeral-port binding rule, and the full list of deliberate mock liberties, before any of the code that implements them.
  2. mock_node, MockNode, MockReply, handle_request (lib.rs): the HTTP JSON-RPC mock at the center of the crate.
  3. CallLog (lib.rs): the call-recording type several integration suites assert against.
  4. sim_chain.rs: SimChain’s fields, then with_blocks, extend, reorg_from, reorg_to, add_log, add_tx, head, hash_of, and handler, in that order; the module doc comment names every liberty the handler takes before the handler itself does.
  5. ws_mock.rs: MockWsNode’s fields and methods, then mock_ws_node and serve_connection; read go_silent last, since it is the one path through this file that deliberately never touches the socket again.
  6. cursors.rs, then pool.rs: the smallest files in the crate, each read in one pass alongside its own module doc comment’s rationale.

blockwatcher-e2e

blockwatcher-e2e is black-box, end-to-end coverage of the real blockwatcher binary: every scenario spawns the binary the way an operator would (a config file, an optional seed directory, environment variables, signals) and asserts on what an operator can actually observe (stdout, stderr, the exit status, and HTTP), never on an internal type. Its own lib.rs states why that library target exists at all while staying empty: “This library target is empty by design and stays empty. The crate exists so tests/ has a workspace member to hang from, and every dependency it declares is a dev-dependency: with no production dependencies at all, the crate structurally cannot ship anything, which is why the dep-graph gate’s allowlist for it is empty rather than populated” (crates/blockwatcher-e2e/src/lib.rs:3-7).

Every scenario follows the same shape: spawn the real binary, point it at whichever chain fixture it needs, and assert only on what an operator could observe from outside the process:

flowchart LR
    test["scenario<br/>tests/e2e/*.rs"] --> spawn["spawn_blockwatcher<br/>harness.rs"]
    spawn --> bin["blockwatcher binary<br/>nested cargo build"]
    bin --> chain{{"anvil node,<br/>or MockNode / SimChain"}}
    bin --> observable["stdout, stderr,<br/>/health, /status"]
    bin -->|"webhook actions"| recv["Receiver<br/>recording webhook server"]
    test -->|"asserts on"| observable
    test -->|"asserts on"| recv

crates/blockwatcher-e2e/Cargo.toml has an empty [dependencies] table and populates [dev-dependencies] only: alloy-primitives, axum, blockwatcher-evm-testkit, reqwest, serde_json, tempfile, and tokio (macros/rt-multi-thread/net/sync/time features). That empty [dependencies] table is what scripts/check-dep-graph.sh’s blockwatcher-e2e allowlist entry names too: an empty string, so any production dependency at all fails the check by construction, the same rule Workspace map § Verifying the rings and Architecture decisions § How the dependency gate turns rules into a mechanical check both describe as one of the two crate-shaped rules the gate checks by name rather than through the general allowlist mechanism.

Key takeaways

  • blockwatcher-e2e is black-box, end-to-end coverage of the real blockwatcher binary: every scenario spawns the built binary and asserts only on what an operator can observe from outside it.
  • Its library target is deliberately empty, and every dependency is a dev-dependency, so the crate structurally cannot ship anything.
  • It declares an empty [dependencies] table, the same rule the dependency gate checks by name.
  • Nothing in the workspace depends on it; it drives the binary from outside rather than being driven by anything.

Staying out of the default build

The root Cargo.toml’s [workspace] table lists blockwatcher-e2e as an ordinary entry in members, and declares no default-members key at all (the root Cargo.toml’s [workspace] members list): every cargo check --workspace, cargo clippy --workspace, and cargo test --workspace invocation in CI, and every bare cargo check/cargo build a contributor runs from the workspace root, includes this crate exactly like any other member. Nothing about default-members is what keeps it out of an ordinary build.

What actually keeps it out is structural, and follows directly from the empty [dependencies] table above: lib.rs compiles to nothing but an empty module, no workspace crate names blockwatcher-e2e in its own [dependencies] (there would be nothing there to depend on even if one tried), and the binary an operator actually runs, built via cargo build --bin blockwatcher or cargo run, never touches this crate at all. This crate’s scenario files only compile and run under cargo test, as the tests/e2e/*.rs integration binary tests/e2e/main.rs roots (tests/e2e/main.rs), and that integration binary in turn spawns a second, independently-built copy of the blockwatcher binary as a subprocess (see Building the binary it drives below) rather than linking against it. An ordinary build produces nothing from this crate worth shipping; only cargo test does anything here at all.

The scenarios in tests/e2e/anvil.rs additionally need a real EVM node on PATH. .github/workflows/ci.yml’s check job provisions anvil via foundry-rs/foundry-toolchain@v1.9.1 immediately before cargo test --workspace --all-features, and sets BLOCKWATCHER_E2E_REQUIRE_ANVIL: "1" on that same step (the check job in .github/workflows/ci.yml), so an absent anvil in that job would be a hard failure rather than a silent skip. The windows-check job runs no tests at all, only cargo check --workspace --locked, and its own comment states why: “e2e needs anvil and stays on the Linux job” (the windows-check and feature-powerset jobs in .github/workflows/ci.yml). See Testing strategy § CI for where this crate’s job appearances fit among the workspace’s other suites.

Responsibilities

  • tests/e2e/harness.rs: the rig every scenario shares. spawn_blockwatcher spawns the built binary with its config, an optional seed directory, and environment, capturing stdout and stderr to files; Scenario is the handle back (wait_for_health, wait_for_exit, sigterm, stdout, stderr), and its Drop impl kills and reaps an orphaned process so one scenario’s failure can never blame the next scenario’s port contention or storage file (harness.rs). Receiver is a recording webhook server every delivery-driven scenario points a sink at (harness.rs). write_chain_seed writes the network/spec/sink/monitor JSON every chain-driven scenario shares (harness.rs). until and anvil_available_or_skip are described in their own sections below.
  • tests/e2e/anvil.rs: the scenarios whose subject is the real binary against a real node: one emitted event reaching a webhook end to end, and a process killed mid-stream (SIGKILL) coming back without losing anything.
  • tests/e2e/auth.rs: the control plane’s bearer gate and the opacity of the token behind it, asserted from outside the process: an unauthenticated or wrongly-authenticated request is refused, an authenticated one succeeds, and the token’s value reaches no log line, no error, and no response body.
  • tests/e2e/shutdown.rs: graceful shutdown with a sink that will never finish; the subject is which clock decides the exit, the configured drain deadline or the wedge.
  • tests/e2e/stdout_purity.rs: the log sink’s line-protocol contract, asserted on the real process’s stdout with tracing enabled and talking: every stdout line is one parseable canonical match JSON object, because every diagnostic goes to stderr instead.

Not this crate’s job: implementing anything a deployed instance loads (this crate is structurally incapable of that; see Staying out of the default build above); exercising a module’s own unit-level or module-level behavior (blockwatcher-evm’s and blockwatcher-storage’s own tests/ do that against a mock node or a real sqlite file respectively; see Testing strategy); or substituting for anvil with a mock when a scenario’s whole point is what a real node does with a transaction, a block, and a confirmation barrier: tests/e2e/anvil.rs’s own module doc comment states this directly: “Nothing here can be answered by a mock” (tests/e2e/anvil.rs:1-9).

Key types and functions

NameKindRole
spawn_blockwatcherasync fnSpawns the built blockwatcher binary with a config, optional seed, and environment, capturing stdout/stderr to files in the caller’s directory (harness.rs)
ScenariostructThe spawned process’s handle: wait_for_health, wait_for_exit, sigterm, stdout, stderr; Drop kills and reaps an orphan (harness.rs)
blockwatcher_binaryasync fnBuilds --bin blockwatcher into a nested target directory on first use, via a blocking cargo build --message-format=json call, and caches the resulting path for the rest of the process (harness.rs)
ReceiverstructA recording webhook server on an ephemeral port; bodies() returns every parsed body received so far, panicking if the listener has stopped (harness.rs)
ChainSeed, write_chain_seedstruct / fnWhat a chain-driven scenario varies about the shared seed (node URL, confirmations, poll interval, sink), and the function that writes that seed’s network/spec/sink/monitor JSON (harness.rs)
untilasync fnThe bounded poll every wait in this crate shares, quoting a captured process’s stderr tail into its panic on timeout (harness.rs)
anvil_available_or_skipfnWhether anvil is on PATH; prints a skip marker and returns false normally, panics instead when BLOCKWATCHER_E2E_REQUIRE_ANVIL=1 (harness.rs)
serialasync fnA static tokio::sync::Mutex guard serializing every scenario that holds it, so a booting child never competes with another scenario’s child for the same starved CPU (harness.rs)
reserve_addrfnA loopback address nothing is listening on, for a scenario that must know the API’s address before the process binding it exists (harness.rs)

Building the binary it drives

blockwatcher_binary builds the real binary once per test process and reuses the cached path afterward (harness.rs). It builds into a separate, nested target directory rather than the outer cargo test invocation’s own one, and nested_target_dir‘s doc comment explains why in cost terms: the two builds “resolve features differently and always will,” since the gate’s --all-features run turns on blockwatcher-portsfakes and testing features for everything downstream of it, while “the binary under test… is built with default features.” Sharing one directory would mean “every gate run pays a near-full rebuild of the workspace — tens of minutes, attributed to a test that appears to hang because the cost is borne by cargo inside it” (harness.rs:164-186). The build itself, inside blockwatcher_binary, runs on a blocking thread via spawn_blocking, specifically so a mock node serving a scenario from the same async runtime keeps answering the process under test while the nested cargo build runs to completion in the background (harness.rs).

The anvil-only scenarios

tests/e2e/anvil.rs hand-assembles the one contract every scenario in this crate that needs a real chain installs via anvil_setCode: runtime bytecode that copies its calldata into memory and logs it under one topic, because, as the module comment states, “this workspace carries no Solidity toolchain” (anvil.rs:46-60):

#![allow(unused)]
fn main() {
fn emitter_runtime(topic: &str) -> String {
    format!("0x3660006000377f{topic}366000a100")
}
}

Both headline scenarios mine blocks themselves, deliberately, because “anvil produces no blocks on its own” (anvil.rs:31-36): emit sends a transaction and polls until it lands in a block, mining along the way, never assuming inclusion on the send response alone; confirm mines until the latest block clears the confirmation barrier a Ping log’s own block needs to become eligible (anvil.rs). The second scenario, sigkill_and_restart_loses_nothing, is the one place in this crate that distinguishes a resumed process from a rescanned one: it kills the first process with SIGKILL rather than SIGTERM (no drain, no final checkpoint), then asserts that the first event delivered before the kill is never delivered a second time by the successor, since a rescan from the seed’s start block would repeat it and a resume from the stored checkpoint cannot (anvil.rs).

Serialization and the shared deadline

Every wait in this crate goes through the same bounded poll, harness::until, which quotes the tail of a captured process’s stderr into its panic message on timeout, since “every way one of these waits fails… is legible only there” (harness.rs:75-99, doc comment). Its budget is a package-wide BLOCKWATCHER_E2E_DEADLINE_SECS environment override on top of a 120-second default, because, in the function’s own words, “a laptop also running a build or a backup can starve a booting child past any default that is still short enough to fail a real regression promptly” (harness.rs:42-66).

shutdown.rs’s scenario and auth.rs’s scenario both take the harness::serial() guard before spawning anything (harness.rs). shutdown.rs’s own call site is the one that explains why, in a comment immediately above the call: “Serialized before anything else: the exit window below is measured in seconds on purpose, so this scenario least tolerates sharing the machine with another booting child” (shutdown.rs:78-80). auth.rs’s own call to serial() (auth.rs) carries no such comment of its own; it takes the same guard, backed by the same serial() function, without stating its own reason inline. anvil.rs’s scenarios take neither: nothing about their own assertions depends on a tight wall-clock margin the way shutdown.rs’s drain-deadline scenario does.

Neighbours

blockwatcher-e2e declares no [dependencies] at all. In [dev-dependencies] only:

  • blockwatcher-evm-testkit: MockNode, SimChain, mock_node, for the scenarios (shutdown.rs, stdout_purity.rs) that need a scripted chain but not a real one; see blockwatcher-evm-testkit § Neighbours for that crate’s side of the same edge
  • alloy-primitives: keccak256, for harness.rs’s ping_topic0 and the emitter contract’s runtime bytecode
  • axum: the recording webhook server Receiver runs
  • reqwest: every JSON-RPC and HTTP call this crate’s scenarios make, including the anvil scenarios’ own hand-rolled JSON-RPC client (this crate does not depend on blockwatcher-rpc)
  • serde_json: every config, seed, and JSON-RPC payload this crate builds or parses
  • tempfile: a fresh scenario directory per test
  • tokio (macros/rt-multi-thread/net/sync/time features): the async runtime every scenario and mock node runs on

No crate in the workspace depends on blockwatcher-e2e (per the dependency table, where its row has no incoming edge and an empty outgoing one): it is a leaf in both directions, driving the binary from outside rather than being driven by anything.

Reading the source

  1. lib.rs: the one-paragraph reason this crate’s library target is empty and stays that way.
  2. tests/e2e/main.rs: the mod declarations that root every scenario file, in file order.
  3. tests/e2e/harness.rs: ping_topic0, EMITTER, word (the shared fixture identity every scenario’s Ping event uses); deadline and until (the shared wait); serial; blockwatcher_binary and build_blockwatcher (the nested build); reserve_addr, write_file, ChainSeed, write_chain_seed (the shared seed); Scenario, spawn_blockwatcher, and its methods; Receiver; and anvil_available_or_skip last, since it is the one piece of this file a contributor without foundry installed hits first.
  4. tests/e2e/anvil.rs: the module doc comment, then emitter_runtime, start_anvil, and the shared emit/confirm/emit_and_confirm helpers, before either #[tokio::test] function.
  5. tests/e2e/auth.rs, tests/e2e/shutdown.rs, tests/e2e/stdout_purity.rs: each is short enough to read in one pass; each states its own headline assertion in its function’s doc comment before the test body.

Testing strategy

blockwatcher’s tests are layered by what they run against, and each layer answers a question the layers around it cannot: a unit layer proves a crate’s own logic against an in-memory stand-in for whatever port it depends on; a module layer proves a real module’s wire-level or storage-level behavior against a real (if local and disposable) backend; and a black-box end-to-end layer proves the assembled binary against a real chain. None of the three substitutes for either of the others, and none of them is optional: Architecture decisions § A trait is only trustworthy if something fake proves it states the rule the first layer exists to satisfy, and the crates this page is about (blockwatcher-testkit, blockwatcher-evm-testkit, blockwatcher-e2e) are the shared scaffolding the second and third layers are built on.

This page does not restate what a fake, a mock, or a harness actually does: blockwatcher-ports documents the fakes and testing features, and the three crate pages above document their own harnesses in full. This page’s job is to say which layer each of them belongs to, and how CI reaches every layer.

Key takeaways

  • Tests are layered by what they run against: unit tests prove a crate’s own logic against an in-memory fake, module tests prove a real module against a real local backend, and end-to-end tests prove the assembled binary against a real chain.
  • None of the three layers substitutes for the others, and none is optional.
  • blockwatcher-testkit, blockwatcher-evm-testkit, and blockwatcher-e2e are the shared scaffolding the second and third layers are built on.

The three layers

Each layer proves something the layers around it cannot:

flowchart TD
    unit["unit tests<br/>fake per port:<br/>FakeSource, MemoryStorage..."] --> module["module tests<br/>real backend, run locally:<br/>mock rpc node, sqlite file"]
    module --> e2e["end to end tests<br/>real binary, real chain:<br/>anvil, blockwatcher-e2e"]

Unit tests: one fake per port

Every port trait blockwatcher-ports declares ships with an in-memory implementation behind its fakes feature: FakeSource, FakeDecoder, FakeMatcher, FakeSink, MemoryStorage, and FlakyStorage (a fault-injecting wrapper around MemoryStorage), each registered through the same ModuleRegistry contract a real module uses. A crate that needs to drive a port without linking any real module enables this feature on its blockwatcher-ports dev-dependency: blockwatcher-api, blockwatcher-core, blockwatcher-evm, blockwatcher-expr, blockwatcher-sinks, blockwatcher-storage, and the blockwatcher binary itself all do, each in [dev-dependencies] only. This is the fakes-per-port rule in its mechanical form: a change to a port’s shape that the matching fake cannot satisfy is a signal the port’s own design needs another look before anything downstream of it does.

blockwatcher-ports also declares a testing feature, gated behind which mockall::automock generates mocks for every port trait (MockSource, MockDecoder, MockMatcher, MockSink) and every storage facet (MockResourceStore, MockCheckpointStore, MockDeadLetterStore, MockPauseStore, MockDeliveryJournal, MockGateStore) (crates/blockwatcher-ports/src/{decoder,matcher,sink,source,storage}.rs, each carrying #[cfg_attr(feature = "testing", mockall::automock)] on its trait declaration). No workspace crate’s Cargo.toml enables that feature on its own blockwatcher-ports dependency: every port substitution anywhere in this workspace’s tests goes through a hand-written fakes implementation instead. The testing feature and its generated mocks still compile, and still get exercised, under the check job’s cargo test --workspace --all-features and the feature-powerset job’s combinatorial build (see How CI runs each layer below); they exist as available unit-level substitution infrastructure that this workspace’s own test suites have not, so far, needed to reach for over a hand-written fake.

Module tests: real backends, run locally

One level up from a port fake, a module’s own tests prove its wire-level or storage-level behavior against something that behaves like the real backend it talks to, not against another abstraction:

  • blockwatcher-storage’s tests/contract.rs runs blockwatcher-testkit’s exercise_storage_contract against the ports fake, its own in-memory module, and its sqlite module over a real file on disk, in turn, so all three are proven equivalent by one shared behavioral contract rather than by shared code. See blockwatcher-testkit § The storage contract.
  • blockwatcher-evm‘s own tests/*.rs and several of its src/ modules’ #[cfg(test)] blocks run the evm-rpc and evm-mempool sources against blockwatcher-evm-testkit’s scripted JSON-RPC and WebSocket mock node and its SimChain fixture, which answers real wire shapes (including reorgs) rather than a chain-agnostic port’s in-memory stand-in. The point of this layer is proving the decode and retry logic against realistic responses, which a Source-level fake has no wire format to get wrong in the first place.

End-to-end: the real binary, against a real chain

blockwatcher-e2e is the one layer that drives the assembled blockwatcher binary itself, as a spawned process, and asserts only on what an operator can observe from outside it. The scenarios in tests/e2e/anvil.rs need a real anvil node rather than any mock, because their assertions are about what a real node does with a transaction, a block, and a confirmation barrier, not about what a scripted handler was told to answer.

How CI runs each layer

.github/workflows/ci.yml declares these jobs, and each one exercises a different slice of the layers above:

JobWhat it runsWhich layer(s)
checkcargo fmt --all --check; cargo check --workspace --locked (default features); cargo clippy --workspace --all-features --all-targets; provisions anvil via foundry-rs/foundry-toolchain@v1.9.1, then cargo test --workspace --all-features with BLOCKWATCHER_E2E_REQUIRE_ANVIL: "1"; ./scripts/check-dep-graph.sh; ./scripts/check-release-version.test.sh; ./scripts/changelog-release-notes.test.shAll three: cargo test --workspace --all-features is the one command that runs every unit test, every module test, and every blockwatcher-e2e scenario (anvil-backed ones required, not skippable) in one pass
windows-checkcargo check --workspace --locked onlyNone: compiles every crate on Windows but runs no tests at all; its own comment states why blockwatcher-e2e in particular is excluded from anything beyond a compile check here: “e2e needs anvil and stays on the Linux job”
feature-powersetcargo hack check --workspace --feature-powerset --depth 2 --lockedNone directly: this job only compiles pairwise feature combinations (including blockwatcher-portsfakes and testing toggled independently), catching a combination that fails to build; it runs no test binary
wikimdbook build docs/wikiNone: this job builds the wiki you are reading, not the workspace’s Rust tests

A vX.Y.Z tag runs .github/workflows/release.yml instead of this table. Its preflight job re-runs the check job’s overlapping steps (including the foundry pin and BLOCKWATCHER_E2E_REQUIRE_ANVIL) plus scripts/check-release-version.sh; it does not repeat windows-check or feature-powerset. The publish job then reflows the matching changelog section into GitHub Release notes, attaches a Linux archive, and pushes both Docker images to GHCR. See CONTRIBUTING.md.

Only the check job actually runs tests, and it runs all of them in one cargo test --workspace --all-features invocation: there is no separate CI job per layer, because the layering above is a property of what each test is written to run against, not of how CI schedules them. The BLOCKWATCHER_E2E_REQUIRE_ANVIL: "1" environment variable on that one step is what turns blockwatcher-e2e’s anvil-backed scenarios from a locally-friendly skip (an absent anvil prints a marker and returns early, so a contributor without foundry installed stays unblocked on everything else) into a hard failure in CI, where anvil was just provisioned and its absence would otherwise be silent. See blockwatcher-e2e § Staying out of the default build for why blockwatcher-e2e is still a full participant in cargo test --workspace despite shipping no production code at all.

Consistency with the rest of the wiki

This page intentionally does not restate what already has a canonical home elsewhere in the wiki:

Extending blockwatcher

Every axis of behavior blockwatcher has is a module behind a port trait, and every module follows the same mechanical steps to become selectable. This page walks through those steps against a module that already ships: the log sink (crates/blockwatcher-sinks/src/log.rs), the smallest real module in the workspace. It is a worked example of the general recipe, not a special case: the same steps apply to a new source, decoder, matcher, gate, or storage backend.

The steps are the whole of the mechanism, but they are not the whole of the work: which trait a module implements decides what it actually has to guarantee, and those guarantees differ sharply from port to port. What each port asks of a module is the per-port half, one section per port, and Extending to a new network covers the one change that spans two ports at once.

This page does not restate what already has a canonical home elsewhere: Modules and trade-offs covers what a module is and how the catalog resolves a name at boot; Architecture decisions § Modules are how an operator makes trade-offs and § The cost-of-change budget state the binding rule and the number a change is judged against; Testing strategy covers the three test layers in full; blockwatcher-ports § Key types and traits tabulates every trait and compiled artifact the ports define. This page’s job is narrower: walk one real module through every step, then state what each port asks of the module behind it.

Key takeaways

  • Every axis of behavior is a module behind a port trait, and every module follows the same mechanical steps to become selectable.
  • The steps are identical for all six ports; this page walks the log sink through them as a worked example, then covers per-port obligations separately.
  • What differs per port is the contract, not the mechanism: cursor ordering for Source, chain-keyed selection for Decoder, bounded compilation for Matcher, an engine-owned journal for Gate, engine-owned retry for Sink, and a shared behavioural contract for Storage.
  • A new network on a chain family a build already carries is configuration and touches no code; a new chain family is a new crate implementing Source and Decoder.
  • Modules and trade-offs and Architecture decisions already state what a module is and the binding rule; this page’s job is narrower: one real module through every step, plus each port’s own obligations.

The steps, in order

flowchart LR
    port["implement<br/>the port trait"] --> config["define the<br/>config type"]
    config --> catalog["register in<br/>the catalog"]
    catalog --> allowlist["allowlist entry<br/>(if a new crate<br/>or dependency)"]
    allowlist --> tests["tests against the<br/>fake / example config"]

1. Implement the port trait

A module implements exactly one port trait. Sink is one method:

#![allow(unused)]
fn main() {
#[cfg_attr(feature = "testing", mockall::automock)]
#[async_trait]
pub trait Sink: Send + Sync {
    /// Deliver one event. The ENGINE owns retry/backoff/dead-letter policy;
    /// the sink reports classified errors and does not retry internally.
    async fn deliver(&self, event: &SinkEvent) -> Result<(), SinkError>;
}
}

(crates/blockwatcher-ports/src/sink.rs)

LogSink (crates/blockwatcher-sinks/src/log.rs) implements it in the most direct way a sink can: deliver writes one line to stdout and returns whatever the write reports:

#![allow(unused)]
fn main() {
#[async_trait]
impl Sink for LogSink {
    async fn deliver(&self, event: &SinkEvent) -> Result<(), SinkError> {
        self.deliver_to(event, &mut tokio::io::stdout()).await
    }
}
}

(log.rs)

A compile-time assertion right next to the struct, rather than a comment, is what actually proves the trait is implemented:

#![allow(unused)]
fn main() {
const _: fn() = || {
    fn assert_port<T: Sink>() {}
    assert_port::<LogSink>();
};
}

(log.rs:23-26)

Whatever error a module’s own logic produces has to become the port’s own error type at the point of contact, never a foreign one handed further in (the same rule Architecture decisions § Foreign shapes get translated once, at the door states in general). LogSink classifies a std::io::Error into SinkError::Delivery with an explicit ErrorClass, and its own doc comment argues the specific choice rather than asserting it:

#![allow(unused)]
fn main() {
/// A broken pipe will not reopen itself, so a reader could argue this
/// should classify as `Permanent`. `Transient` is the safer default: the
/// engine's retry budget bounds how long a truly dead consumer costs, while
/// a wrong `Permanent` classification dead-letters a match that a
/// momentarily blocked consumer (a slow reader, a full OS pipe buffer)
/// would have accepted on the next attempt. Losing a match to an
/// under-classified retry budget is not possible; losing one to an
/// over-eager `Permanent` is.
fn transient(e: std::io::Error) -> SinkError {
    SinkError::Delivery {
        message: format!("stdout write failed: {e}"),
        class: ErrorClass::Transient,
    }
}
}

(log.rs:64-77)

2. Define the config type

A module’s config is a private serde-deserialized struct with deny_unknown_fields, reachable only through the module’s own factory, never a public type another crate could reach into directly. log takes none, and still enforces the rule on an empty shape, so an unrecognized key is a boot-time typo rather than a silent no-op:

#![allow(unused)]
fn main() {
#[derive(serde::Deserialize, Default)]
#[serde(deny_unknown_fields, default)]
struct Config {}
}

(log.rs:86-88)

3. Register a name and a factory, right beside the code

A module declares its own selectable name and how to build itself through blockwatcher-ports’ one registration contract, ModuleRegistry (crates/blockwatcher-ports/src/registry.rs): a NAME constant, a Factory type alias (one of the *Factory aliases beside each port trait), and a factory() that returns a function from opaque JSON config to a constructed Arc<dyn Port>. log’s registration is the whole of its public surface beyond LogSink itself:

#![allow(unused)]
fn main() {
impl ModuleRegistry for Registry {
    const NAME: &'static str = "log";
    type Factory = SinkFactory;
    fn factory() -> Self::Factory {
        |config| {
            Box::pin(async move {
                let Config {} =
                    serde_json::from_value(config).map_err(|e| SinkError::InvalidConfig {
                        message: format!("invalid log config: {e}"),
                    })?;
                Ok(Arc::new(LogSink) as Arc<dyn Sink>)
            })
        }
    }
}
}

(log.rs:102-116)

A crate that ships more than one module in a family folds every registration into one get_all() enumeration, and that enumeration, not the trait impl, is what actually makes a module selectable: a module absent from it can never be selected by config, however completely it implements Sink. Adding log to blockwatcher-sinks’ family enumeration is one entry:

#![allow(unused)]
fn main() {
pub fn get_all() -> Vec<(&'static str, SinkFactory)> {
    vec![
        (crate::log::Registry::NAME, crate::log::Registry::factory()),
        (
            crate::webhook::Registry::NAME,
            crate::webhook::Registry::factory(),
        ),
        (
            crate::script::Registry::NAME,
            crate::script::Registry::factory(),
        ),
    ]
}
}

(crates/blockwatcher-sinks/src/registry.rs:9-21)

4. Register in the composition root’s catalog

get_all() only enumerates a family within one crate; a running binary still has to fold every family’s enumeration into one ModuleCatalog (crates/blockwatcher-core/src/catalog.rs). The composition façade blockwatcher-embed does that once, per family, feature-gated so what a build can select is exactly what it linked. The blockwatcher binary calls the same build_catalog; it must not keep a second registrar.

#![allow(unused)]
fn main() {
#[cfg(feature = "sinks")]
for (name, factory) in blockwatcher_sinks::registry::sinks::get_all() {
    catalog.register_sink(name, factory)?;
}
}

(crates/blockwatcher-embed/src/catalog.rs)

Another module joining a family a build already carries (a new sink beside the ones a build already registers, webhook, script, and log) touches only step 3 above: its crate’s own get_all() gains one entry, and this composition-root fold picks it up automatically on the next build, because the fold iterates the enumeration rather than naming each module by hand. Only a brand-new family joining the binary for the first time (a new chain family’s sources and decoders, or a first module behind a port this binary has never carried before) needs a new block here, and, if it arrives in its own crate, a new feature flag beside evm, expr, and sinks in crates/blockwatcher/Cargo.toml (blockwatcher (binary) § Feature-flag wiring covers the flag wiring itself).

5. Update the dependency gate’s allowlist, when the change needs it

scripts/check-dep-graph.sh enforces every crate’s allowed dependencies by name; Architecture decisions § How the dependency gate turns rules into a mechanical check covers its two-layer allowlist-plus-denylist mechanics in full, so this page only states when adding a module actually touches it: a module added to a crate whose allowlist already covers every dependency that module needs (as log does; it adds no dependency blockwatcher-sinks’s existing ALLOW_BLOCKWATCHER_SINKS entry does not already carry) touches nothing here at all. The script’s allowlist needs an edit only when the change also introduces something new to the dependency graph: a brand-new module crate needs its own ALLOW_BLOCKWATCHER_<CRATE> entry (and a FAMILY_EXEMPT_BLOCKWATCHER_<CRATE> entry if it pulls a denylisted family such as a chain SDK or an HTTP client on purpose) and its name added to ALLOW_BLOCKWATCHER_EMBED, the crate build_catalog lives in. A module added to an existing crate needs an edit only if it brings a dependency that crate did not already declare. The binary’s ALLOW_BLOCKWATCHER entry is not that list: evm / expr / sinks reach the process through embed, not as direct dependencies of blockwatcher.

6. Add tests against the port’s fake, or the family’s own contract

Which test infrastructure a new module tests against depends on what already exists for its port:

  • Every port ships an in-memory fake behind blockwatcher-portsfakes feature (FakeSink, FakeSource, FakeDecoder, FakeMatcher, MemoryStorage), registered through the same ModuleRegistry contract a real module uses. Something else that needs a sink without linking yours reaches for FakeSink instead, the same way blockwatcher-sinks’s own dev-dependency on blockwatcher-core exercises a real sink through blockwatcher-core’s actual engine and sink-worker machinery rather than a reimplemented harness. Storage additionally has a shared behavioral contract, exercise_storage_contract (blockwatcher-testkit § The storage contract), that blockwatcher-storage’s own module and the ports fake both run against, so a new storage module is proven equivalent to the existing ones by the same contract rather than by shared code; no other port has an equivalent shared runner. Source has the next closest thing, blockwatcher-testkit’s recv and stop (blockwatcher-testkit § Driving a source): not a contract over a whole backend, but deadline-bounded drivers that fail a source which stops emitting or ignores its cancellation token instead of hanging the suite.
  • Every module crate proves its own registered modules construct from their own documented example config, in one self-verifying test per crate. blockwatcher-sinks’ version is sinks_get_all_constructs_every_entry_from_its_documented_example_config (registry.rs): it reads each module’s registry_examples/<name>.json and runs the module’s factory against it, so the enumeration in step 3 and the example config in a module’s own rustdoc header cannot drift apart. Adding a module means adding one match arm here alongside its own registry_examples/<name>.json.
  • A module’s own #[cfg(test)] block proves what is specific to it: log proves the emitted line is the canonical body plus one newline, in the_emitted_line_is_the_canonical_body_newline_terminated (log.rs), that concurrent deliveries never interleave a line, in concurrent_large_deliveries_never_interleave_their_lines (log.rs), and that an unrecognized config key is rejected as SinkError::InvalidConfig rather than silently ignored, in factory_rejects_unknown_config_fields_as_invalid_config (log.rs).

Testing strategy’s layering is what these three map onto: the fake and the family-completeness test are unit-layer proof that construction and substitution work; a module’s own test suite that exercises something wire-level or storage-level (a real sqlite file, a scripted mock node) is the module layer above it.

What each port asks of a module

Steps 1 through 6 do not change from port to port. What a module has to guarantee does, and the guarantees are not symmetric: Sink is one method with one rule, while Source carries an ordering invariant nothing checks at compile time and Storage has the widest surface of the six. Each port’s own trait file in blockwatcher-ports is the authority, and its doc comments argue each rule rather than assert it; the sections below are the contributor’s map of what to satisfy and which shipped module to read as a reference.

The obligations worth listing here are the ones a compiler cannot enforce. A module that violates one of them still builds, still registers, and still runs, so each is stated with the failure it produces.

Source

  • Trait (crates/blockwatcher-ports/src/source.rs): run is required. scan (bounded history fetch, behind a monitor dry run’s fetch) and confirmed_tip (the confirmed head for a cold start) both default to SourceError::Unsupported, so a streaming-only source implements neither and stays a fully valid source.
  • Reference modules: evm-rpc and evm-mempool (blockwatcher-evm § The sources).
  • Selected on: a network resource’s source.module.
  • Test infrastructure: FakeSource, plus blockwatcher-testkit’s recv and stop for driving a running source under a deadline.

Obligations run carries beyond its signature:

  • Non-decreasing cursor order, from one loop. The processor’s checkpoint tracker treats arrival order on ctx.events as cursor order. A source that reorders events, or that shares the sender across concurrent producers, publishes a corrupt checkpoint rather than failing, which is the silent loss this project ranks worst. A single source-owned loop satisfies this by construction; nothing else does.
  • The source owns the cursor mapping. Core needs only ordering and serialization and never interprets primary or secondary (crates/blockwatcher-types/src/cursor.rs). EVM’s confirmed source uses primary = block number and packs secondary as (kind << 32) | index so a block’s transactions order ahead of its logs; the mempool source uses primary = arrival counter, secondary = 0. A new source picks its own mapping and documents it, because that mapping is what journal_depth, lag reporting, and rewind ranges are all denominated in.
  • send().await is the backpressure. ctx.events is bounded; a source that drops rather than awaits turns a slow pipeline into missing matches.
  • Cancellation is a normal end. ctx.cancel firing means return SourceOutcome::Ended: not an error, not an invalidate, not a panic.
  • A rewind is a value, not an error. SourceOutcome::Invalidated { from } is how a source reports that everything past from was on a dead fork, and the engine handles it as a control path (Delivery guarantees covers the retract pass it triggers). A source over a feed with no finality, such as a mempool, never produces it.
  • ctx.interest narrows fetching, never matching. Hints are eventually consistent with the monitor set, never synchronised with it, so a source that used a hint to decide what matched would drop matches for monitors added inside that window. The pipeline decides what matched. referenced_fields being None means unknown, which is why an empty set and an absent set are different values.
  • ctx.status is what an operator sees. Live, CatchingUp, and their optional head cursor are what the control plane reports lag against the pipeline’s checkpoint; a source that never publishes status reports no lag rather than no problem.
  • Checkpoint.source_state is module-private. Nothing else reads it. The engine stamps Checkpoint.module and refuses to resume a checkpoint written by a different source module, because a cursor is only meaningful in its writer’s units. A source over a feed that cannot replay from a stored position must document, as its own trade-off, what its checkpoints mean.

Decoder

  • Trait (crates/blockwatcher-ports/src/decoder.rs): chain, compile_spec, compile, and decode are required. interest defaults to no hints and merge_interest defaults to unioning addresses and signatures.
  • Reference module: evm (blockwatcher-evm § The decoder).
  • Selected on: nothing. This is the one port an operator never names.
  • Test infrastructure: FakeDecoder, plus a golden-file suite (crates/blockwatcher-evm/tests/golden_schemas.rs, golden_decode.rs).

A decoder is chosen by chain rather than by name, and that changes steps 2 and 3 for it specifically. At boot the engine constructs one instance of every registered decoder module, each with an empty config object, indexes them by whatever chain() returns, and refuses boot with EngineError::DuplicateChainDecoder when two modules claim the same ChainKind (validate_and_build, crates/blockwatcher-core/src/engine/boot.rs). ChainKind is an open string set (crates/blockwatcher-types/src/id.rs), never an enum, so a new family adds a value and touches no existing type. Because every decoder is constructed from {}, a decoder has no useful per-module config: define the empty Config with deny_unknown_fields anyway, exactly as log does in step 2.

Obligations:

  • Translate once, at the door. CompiledSpec::new(schemas, inner) splits a chain artifact into the chain-agnostic SchemaSet the engine, API, and matcher work from, and a module-private inner only this family downcasts. Nothing chain-shaped may travel further in (Chain-agnosticism § Core must not know chains).
  • compile resolves only the specs its selectors name. specs is the whole chain’s set, and a RawSelector whose spec is None is not licence to read every spec on the chain. The control plane depends on this: deleting a spec no stored monitor names skips the pipeline restart a changed spec forces, precisely because no compiled set can contain it. A decoder that resolved more than it named would keep serving an artifact built from a deleted spec.
  • Same event name, two field sets, is a decision the decoder must make. The port deliberately leaves it open: union the declarations or reject the ambiguity, but a dropped declaration must not be invisible. FakeDecoder keeps the first and discards the second, which is a test-double convenience, not a pattern to imitate.
  • Declared field names must match the shapes decoded. Predicate compilation matches a schema field name flat, so "order.maker" is one name, while evaluation traverses the decoded event (order then maker, digit segments indexing arrays). A decoder that declares a dotted name and emits it as a literal key ships monitors that compile cleanly and never fire. A flat digit-bearing declaration is the same trap from the other side: args.path.0 already type-checks through a declared path: Array(T) by derivation, and declaring "path.0" as well shadows that derivation, type-checking the predicate against one type while the event delivers another. Every decoder’s golden-file suite therefore has to pair each dotted or digit-bearing declaration with the event shape it decodes to, so the contract is executable per module rather than prose.
  • decode output order is deterministic. Match identity derives from each event’s index in that output, so a reordering changes ids and breaks downstream deduplication. A payload this decoder recognizes as malformed is DecodeOutcome::undecodable(), which the engine counts; a payload no selector wanted is no_match(), which is not a failure.
  • Filling chain_specific obliges overriding merge_interest. The default drops it, because only the decoder that produced an erased payload knows how to combine two of them. A decoder that populates it and does not override loses every hint past the first monitor, silently.

Matcher

  • Trait (crates/blockwatcher-ports/src/matcher.rs): compile and matches are required. explain defaults to Explanation::Unsupported and referenced_fields to None, so a matcher that supports neither is still fully valid.
  • Reference module: expr (blockwatcher-expr, and Predicates and the expression language for the language it implements).
  • Selected on: [engine].matcher in blockwatcher.toml, instance-wide. One matcher runs per process, and EngineConfig has no overall default because it refuses to guess which. A control surface that recompiles a monitor compiles against the running instance, never a fresh one, because a predicate compiled against a different instance is not guaranteed to mean the same thing to the one evaluating it.
  • Test infrastructure: FakeMatcher, plus blockwatcher-expr’s property tests (crates/blockwatcher-expr/src/proptests) as the model for a language module: round-trip, integer edge cases, three-valued logic, and a no-panic sweep.

Obligations:

  • compile must bound its own work. The predicate source is operator-controlled. The engine bounds the length it hands in, but a bounded input can still spell unbounded work, and a compile that can be driven to exhaust a stack or heap is a write-path denial of service. This is the obligation most easily missed by a recursive-descent parser written without an explicit depth limit.
  • A missing field is Ok(false), never an error. matches is the hot path, and the third truth value is unknown rather than failure.
  • referenced_fields spells paths as the predicate source spells them (tx.gas, args.order.maker). None means this matcher cannot introspect itself, which forces every caller to assume any field may be read. That is why None is the default: it is the answer that cannot cause a source to skip work a monitor depended on.

Sink

The worked example above, in full. In summary:

  • Trait (crates/blockwatcher-ports/src/sink.rs): deliver is the only method, with no defaulted ones.
  • Reference modules: log, webhook, script (crates/blockwatcher-sinks/).
  • Selected on: a sink resource’s module.
  • Test infrastructure: FakeSink.
  • Obligations: the engine owns retry, backoff, and dead-lettering, so a sink that retries internally is a bug; it reports a classified SinkError::Delivery and returns. Both SinkEvent::Match and SinkEvent::Retracted are at-least-once, so a sink must be idempotent on match_id in both directions (Delivery guarantees).

Gate

  • Trait (crates/blockwatcher-ports/src/gate.rs): compile, on_hit, default no-op on_invalidate.
  • Reference modules: threshold, max_once (crates/blockwatcher-gates/).
  • Selected on: a monitor resource’s gate.module.
  • Test infrastructure: PassthroughGate (always Emit([this])).
  • Obligations: the engine owns the journal, so a module that opens storage is a bug. On invalidate the engine already prunes gate_hits with cursor > from; a module must not drain-all in on_invalidate. P11: two real modules plus the fake already satisfy the port; a third module is a new crate, not a core enum arm. See Gates.

Storage

  • Trait (crates/blockwatcher-ports/src/storage.rs): the widest of the six, covering resource CRUD under optimistic concurrency, checkpoints, dead letters, and the bounded delivery journal. Only put_batch is defaulted, as a plain loop over put.
  • Reference modules: memory and sqlite (blockwatcher-storage, whose § The Storage port contract is the canonical statement of the rules below).
  • Selected on: [storage] in blockwatcher.toml, instance-wide.
  • Test infrastructure: the only port with a shared behavioural contract, exercise_storage_contract. A new backend runs exactly the sequence memory, sqlite, and the ports fake already run, which is what proves it equivalent to them without sharing code. FlakyStorage wraps a backend to inject failures on top.

Obligations:

  • Optimistic concurrency, never a silent overwrite. expected_version: None creates and conflicts if the id exists; Some(v) updates and conflicts on mismatch, NotFound when absent. A create returns version 1. A rejected write must leave state untouched, which the contract checks directly.
  • list and list_checkpoints are unpaginated by contract. Resource and checkpoint counts are operator-scale, not event-scale, so a backend must not invent paging that callers would silently truncate against.
  • record_delivery prunes inside the same write. Rows whose cursor.primary falls strictly behind cursor.primary.saturating_sub(journal_depth) are dropped as part of recording, and re-recording a match id replaces its row. A backend that prunes lazily lets the journal grow without bound (Delivery guarantees § The delivery journal).
  • Round-trip values and cursors beyond a typed column’s range. Cursors are u64 and decoded values carry arbitrary-precision integers, so a backend built on signed 64-bit columns has to say how it stores them; the contract exercises exactly this.
  • Overriding put_batch is what atomicity means here. The default is correct everywhere and atomic nowhere: a mid-batch failure leaves earlier entries written. A backend with a native transaction overrides it, and the one caller that needs the guarantee, the seed path, says so in its own docs.

Extending to a new network

Two different changes both get called “adding a network”, and only one of them is code. Which one applies depends entirely on whether the chain’s family already has a decoder in the build.

flowchart TD
    q{"is a Decoder for this<br/>chain already registered?"}
    q -->|yes| conf["configuration only:<br/>Network + Spec resources"]
    q -->|no| fam["new chain family:<br/>new crate with a Source<br/>and a Decoder"]
    conf --> nothing["no crate, no feature flag,<br/>no allowlist entry"]
    fam --> steps["steps 1 to 6, twice,<br/>plus a feature flag<br/>and dep-gate entries"]

A new network on a chain family the build already carries

An EVM chain blockwatcher has never seen is not a contribution at all. It is a Network resource naming a source module the build already registers and that module’s own endpoints, plus Spec resources whose chain is evm. Nothing is compiled, no module is written, no allowlist changes, and the running instance picks it up through the control plane (Your first monitor walks the writes; Resources § The kinds and their fields is the field reference). This is the case that should cover most chains, and it is the payoff for the decoder boundary: the chain differs, the code does not.

A new chain family

A family blockwatcher has no decoder for is where code is written, and it is the one change that spans two ports at once: at minimum one Decoder claiming a new ChainKind and one Source producing raw events for it. Everything above the decoder, every predicate, the matcher, every sink, and all of storage, works against it unmodified, because none of it was ever written against EVM’s types to begin with.

Beyond running steps 1 through 6 for each of the two modules, a new family touches:

  • One new crate, blockwatcher-<family>, holding both modules plus that family’s own sources::get_all() and decoders::get_all() enumerations. crates/blockwatcher-evm is the shape to copy: separate source/ and decoder/ trees, one registry module, one registry_examples/<name>.json per registered module.
  • A ChainKind value, returned by the decoder’s chain() and written on every Spec for that family. A new string, not a new variant, so no existing type changes.
  • A cursor mapping, chosen by the source and documented as its own trade-off, because journal_depth, lag reporting, and invalidate ranges are all denominated in it.
  • Normalization onto the canonical value tree, so a decoded field on the new family is the same Value an existing predicate already reads (Chain-agnosticism § One value model, every chain).
  • Two registration blocks in crates/blockwatcher-embed/src/catalog.rs, one for the family’s sources and one for its decoders, feature-gated together, plus a feature flag beside evm, expr, and sinks in both crates/blockwatcher-embed/Cargo.toml and crates/blockwatcher/Cargo.toml, where the binary’s flag forwards to embed’s. This is the “brand-new family” case step 4 names, and it is the only case that touches the composition façade.
  • Dependency-gate entries: an ALLOW_BLOCKWATCHER_<FAMILY> list of the crate’s direct dependencies, a FAMILY_EXEMPT_BLOCKWATCHER_<FAMILY> entry for the chain SDK it deliberately pulls, and the crate’s name added to ALLOW_BLOCKWATCHER_EMBED. The exemption relaxes only the transitive family denylist, never the direct allowlist, and it relaxes it only for the families named: the forbidden edge from a chain SDK into the core ring stays blocked (Chain-agnosticism § The CI gate that proves it).
  • A golden-file suite pairing every schema declaration the decoder produces with the event shape it decodes to, which is the per-module form of the field-name contract above.
  • Documentation: a crate page under Workspace map and a row per module in Modules and trade-offs § The module catalog, so the new modules are visible to an operator choosing between them.

A second source for a family that already has a decoder, such as a different acquisition strategy for EVM, is not this case. It is the cheap case from step 3: one new file plus one entry in that crate’s existing sources::get_all().

Documenting trade-offs where a reader will see them

Architecture decisions § Modules are how an operator makes trade-offs states the rule: a module writes down its own trade-offs where a reader choosing between modules will actually see them, not in a document separate from the code. log’s crate-level doc comment does exactly that, right above the code it documents (log.rs): zero external dependencies and zero configuration make it the cheapest way to smoke-test a pipeline end to end, delivery is only as durable as whatever consumes stdout, there is no acknowledgement beyond the write succeeding, and a composition root that selects it must route diagnostics elsewhere or an interleaved non-JSON line breaks a consumer’s parse.

The registration function’s own doc comment, on Registry (log.rs), carries the second half of the same idea, pointing a reader at LogSink itself for the trade-offs above and explaining why the example below it cannot go stale: the example is include_str!-ed straight from the module’s own registry_examples/log.json, the same file the family-completeness test in step 6 reads, so the text a reader sees in rendered documentation and the text a test actually constructs from cannot diverge:

#![allow(unused)]
fn main() {
#[doc = concat!("```json\n", include_str!("registry_examples/log.json"), "```")]
pub struct Registry;
}

(log.rs:99-100)

Modules and trade-offs § The module catalog is where every shipped module’s trade-off, log’s included, is collected into one exhaustive table; this page does not repeat that table.

The cost-of-change budget

Architecture decisions § The cost-of-change budget states the number precisely: adding a new module should touch one new file (or one new crate) plus one registration line in the composition root, and nothing else. Reading the steps above against that budget:

  • A module added to a family a build already carries (another sink beside the ones a build already registers) is the cheap case: one new file (the module itself) plus one line in that crate’s own get_all(). It touches no core crate, no composition-root file, and no allowlist entry unless the new module’s own dependencies demand one.
  • A brand-new family joining the binary for the first time is the one case that also touches the composition façade: one registration block in crates/blockwatcher-embed/src/catalog.rs, and, if the family lives in a new crate, one new ALLOW_BLOCKWATCHER_<CRATE> entry plus the crate’s own name added to ALLOW_BLOCKWATCHER_EMBED (and ALLOW_BLOCKWATCHER if the binary links it too). This is the “one registration line” the budget names, and a change that needs more than that is expected to explain why, the same way a size-budget overrun is a prompt to ask a question rather than an automatic rejection.

Using a module once it is compiled in

Everything above is the contributor side: getting a module into a binary. An operator who already has that binary selects the module by name, in whichever resource or instance-config section that module’s port family is configured on:

Port familySelected onCovered in
Sourcea network resource’s source.moduleResources § The kinds and their fields
Decoderautomatic, from a spec’s chainResources § The kinds and their fields
Sinka sink resource’s moduleResources § The kinds and their fields
Matcher[engine].matcher in blockwatcher.toml, instance-wideConfiguration reference § [engine]
Gatea monitor resource’s gate.moduleGates
Storage[storage] in blockwatcher.toml, instance-wideConfiguration reference § [storage]

Every one of these is the same envelope: a module name plus that module’s own opaque config object. A name the running binary never registered, whether because of a typo or because the build simply was not compiled with that module’s crate or feature flag, is refused at write or boot time with the list of names this particular build actually carries, never a superset the workspace merely contains somewhere else (Resources § Write-time validation covers the refusal path for resources in full; blockwatcher (binary) § Feature-flag wiring covers exactly which registrations a feature flag removes).

Architecture decisions

blockwatcher’s contributor-facing rulebook is a local, project-internal document that this wiki does not link to or quote. The page below restates its binding decisions from scratch, in the wiki’s own words, so a reader never needs the original to understand why the workspace is shaped the way it is.

Three goals sit above every individual rule, and each rule below traces back to one of them:

  1. A monitor that quietly misses an event has failed at its one job, so correctness of delivery outranks everything else.
  2. Adding the next chain, source, or sink should cost about what adding the first one did, measured concretely as how many files a contributor has to touch.
  3. One way to solve a given problem, applied everywhere it recurs, beats a clever alternative used once: a codebase with a single learnable shape stays cheap to extend long after the person who wrote a rule has moved on.

Key takeaways

  • This page restates blockwatcher’s contributor rulebook from scratch, in the wiki’s own words, so a reader never needs the original document.
  • Three goals sit above every rule: correctness of delivery outranks everything else, adding the next module should cost about what the first one did, and one way to solve a recurring problem beats a clever alternative used once.
  • Every rule below is a hard constraint: a change that violates one is either wrong or the rule itself needs to be amended in writing first.

The binding rules

Each of the following is treated as a hard constraint, not a guideline: a change that violates one is either wrong or the rule itself needs to be amended in writing before the change lands.

Chain knowledge stays out of the core

blockwatcher-types, blockwatcher-ports, blockwatcher-core, and blockwatcher-api (the core ring) may never depend on a chain SDK, whether directly or through some other dependency’s own dependency tree. Everything that knows a specific chain’s wire format lives in a module crate instead. This is not policed by a human remembering to check; scripts/check-dep-graph.sh reads each crate’s actual manifest and its full transitive dependency tree in CI and fails the build the moment a forbidden package family shows up anywhere in a core crate’s closure. The script is built as an allowlist of what a crate may depend on, not a blocklist of what it may not: a blocklist only ever catches the specific package someone thought to write down in advance, while an allowlist rejects anything unrecognized by default, including a brand-new chain SDK nobody has heard of yet.

There is exactly one way behavior varies

When blockwatcher needs pluggable behavior, it reaches for one mechanism: a Rust trait that can be called through a dyn pointer, implemented by whichever module wants to provide that behavior. Nothing in core ever branches on which chain it is talking to. A chain-specific value crosses from a module into core as an opaque payload carrying a tag naming which chain family produced it, and only that same family’s own module code is allowed to open the payload back up. Core just carries it through. Wherever blockwatcher’s vocabulary needs a name that a chain family invents for itself (an event kind, a namespace inside a predicate), that name is an open slot a family fills in on its own rather than a fixed list core would have to grow for every new chain. Vocabulary that core itself owns completely and reasons about exhaustively (the canonical value model, the error-retry categories, and what a sink may receive — today Match or Retracted { match_id } under one dyn Sink / deliver) stays a closed set on purpose, because matching over it exhaustively is exactly what keeps that vocabulary safe to extend. A new kind of delivery must earn a closed variant here, not arrive as an untyped side channel.

Modules are how an operator makes trade-offs

Every configurable piece of behavior (a source, a decoder, a matcher, a gate, a sink, a storage backend) is a module: it implements exactly one trait, declares its own name and how to build itself right next to its own code (never in a table shared across modules that could fall out of sync), validates its own configuration and rejects anything it does not recognize, and writes down its own trade-offs (cost, latency, how complete its coverage is) where a reader choosing between modules will actually see them. There is exactly one registration path for a module to become selectable; a second, faster-feeling way to wire one in is treated as a defect the moment it appears, because letting two registration patterns coexist is how a codebase quietly stops having one learnable shape.

A trait is only trustworthy if something fake proves it

Every trait behind which behavior varies ships with an in-memory implementation that is exercised in CI the same way a real implementation would be. These fakes are not test convenience; they are the standing proof that a trait’s shape leaks nothing chain-specific or backend-specific: a change to a trait that a fake cannot satisfy is a signal that the trait’s design needs another look before anything else about the change gets reviewed.

Foreign shapes get translated once, at the door

Each place blockwatcher’s code meets something foreign (a chain’s contract artifact, a chain’s native value encoding, an SDK’s own error type) converts it into blockwatcher’s own vocabulary at that single point of contact, inside the module that owns the boundary, and nothing further downstream ever has to re-learn the foreign shape. A contract artifact becomes a chain-agnostic compiled schema once, at write time, inside the deciding module’s own decode step. A chain’s native value becomes a canonical value model entry inside the same module’s decode function, which is why there is never more than one implementation of value comparison or evaluation regardless of how many chains blockwatcher supports. An SDK’s error type becomes the relevant port’s own error enum inside that module’s adapter code. Core is never handed a raw SDK error type to interpret.

Data moves forward, on purpose, and never silently

Stages inside a running pipeline hand work to each other over fixed-capacity queues, and a stage that produces faster than the next one can keep up simply waits: this is backpressure, and it replaces every form of “drop the newest thing because the buffer is full.” Any channel whose job is to carry something that must be delivered may never be a lossy one; a lossy channel is reserved for observability taps whose drops are themselves counted and which never carry anything load-bearing. Delivery is guaranteed at least once: the position a pipeline resumes from after a restart only moves past an event once every consequence of that event has either reached its destination or been recorded as undeliverable, so a crash can produce a repeated notification, never a missing one, and every notification carries an identifier a consumer can use to recognize a repeat. An undo is the same class of work as an apply: Retracted shares the delivery helper, retry budget, dead-letter write, and completion guard that Match uses — not a second retry stack and not a best-effort log line. Where a later invalidate must name what was already delivered, that fact is journaled before the completion guard completes; retention of that journal is bounded, and anything an invalidate can no longer reach is counted and logged rather than dropped quietly. A gate Retain/Discard completes with outstanding 0 and does not stall that prefix; only an Emit journals a delivery. On invalidate the engine prunes gate_hits by cursor (cursor > from), then retracts already-emitted matches as today. The operational sequence (drain, prune, retract, rewind, restart) and the retention knob live in Delivery guarantees, not as a second pattern beside this one. See Gates for the sixth port’s axis: when a predicate-true hit becomes a delivery.

Anything short of success is a value, not a crash

States like “nothing new yet,” “running behind,” or “given up after retrying” are ordinary return values an operator can query through the status API, not Err results that vanish into a log line. Anything that does require operator attention carries enough structured detail (what failed, where, how badly) to act on without reproducing the failure by hand; only truly incidental internal failures get away with a plain string. Every fallible call’s result is used somehow: handled, returned, or logged with enough context to matter; discarding a result silently is treated the same as never having called it. A positively detected deep invalidate is the same kind of value: the source returns it from run as a typed outcome, not a SourceError, and core never names a chain, a reorg, or a confirmation depth — only the invalidate. The module that talks to the chain decides when a fork is deep enough and translates at its own boundary. How the engine drains, retracts, and rewinds after that outcome is part of Delivery guarantees.

The system checks itself before it starts, and never takes its neighbors down

Nothing gets constructed at boot until every piece of configuration for every module (including one that will not actually be used) is confirmed valid, and until every persisted resource is confirmed to still compile against its schema. Any failure at this stage aborts the process with a message that lists the valid alternatives rather than leaving the operator to guess. Once running, a module that starts failing degrades its own corner of the system loudly (counted errors, visible status) and never brings down the modules around it, and never fails in a way nothing logs or counts.

Anything reusable is compiled once, ahead of the event that needs it

A predicate, a selector, or any other user-supplied specification is parsed, type-checked, and turned into a ready-to-run artifact the moment it is written (through the API or at boot from a seed file), never while an event is actually flowing through the pipeline. A typo in one of these becomes an immediate, specific rejection at write time rather than a monitor that silently never fires. The path a live event actually travels does the minimum possible work: run the already-compiled selector, run the already-compiled predicate, nothing else.

Dependencies always point toward the vocabulary, never toward the engine

A module or library crate may depend on the vocabulary crates (blockwatcher-types, blockwatcher-ports) but never on blockwatcher-core itself, which is what keeps every module usable from some other binary entirely, not only from blockwatcher’s own composition root. The glue crates (blockwatcher and blockwatcher-embed) are the exception that may depend on blockwatcher-core: they assemble a process, they are not modules. The vocabulary crates in turn depend on almost nothing: blockwatcher-types pulls in serialization support and nothing that assumes an async runtime, a web framework, or a chain SDK. A crate that genuinely needs a heavy dependency is the one crate that carries it, rather than spreading it into something shared. Re-exporting another crate’s items is always explicit, never a blanket glob, so a reader can see exactly what a crate’s public surface actually is.

Nothing gets built before two real users need it

An abstraction earns a place in the codebase once there are two genuine implementations behind it, or one implementation plus a fake standing in for a second design that has actually been thought through, not because some earlier, unrelated project happened to have that feature. When it is unclear whether something is needed yet, the default is to leave it out; the module system exists specifically so a genuine need can be added later without redesigning anything.

Every signal names what produced it

A metric, a log line, or a status readout always carries enough identity (which module, which pipeline, which upstream endpoint) that a reader can act on it without cross-referencing something else first. Reporting only that something is failing, without saying which module or which endpoint, does not count as observability. Anything the system drops, caps, or truncates is counted and logged rather than disappearing quietly.

Growth past a size budget is a question, not an automatic rule break

Files, crates, and the core ring as a whole carry soft size targets, checked in review rather than by a script: production code is what counts against them, and a test suite that pins down a genuinely hard-won invariant is judged by whether it still reads as proof of one concept, not by its line count. Going over a budget is allowed: it is a deliberate prompt to ask whether a concept is missing that would make the code smaller again, not a violation to explain away.

The cost-of-change budget

The clearest number in the whole rulebook is this one: adding a new module should touch one new file (or one new crate) plus one registration line in the composition root, and nothing else. Bumping a chain SDK’s version should force a rebuild of one crate (the module crate that chain family lives in), not the rest of the workspace. Both numbers are targets a reviewer checks a change against, not hard gates a script enforces; a change that touches more files than that budget suggests is expected to explain why, in the same way a size budget overrun is a prompt to ask a question rather than an automatic rejection.

How the dependency gate turns rules into a mechanical check

Every crate in the workspace goes through the same two-layer check on every commit:

flowchart TD
    crate["a workspace crate"] --> direct{"every direct dependency<br/>on this crate's own allowlist?"}
    direct -->|"no"| fail["ci fails,<br/>naming crate and package"]
    direct -->|"yes"| tree{"full transitive tree<br/>free of denylisted families,<br/>or exempted?"}
    tree -->|"no"| fail
    tree -->|"yes"| pass["ci passes"]

Most of the rules above would be easy to state and easy to drift away from without something outside code review holding them in place. scripts/check-dep-graph.sh is that something, and it runs on every commit in CI. It works in two layers:

  1. A direct-dependency allowlist, per crate. For every crate in the workspace, the script names exactly which other crates and external packages it may declare in [dependencies]. A crate with no entry in this list fails outright: there is no default “allow unless denied,” so a brand-new crate that forgets to declare its intended shape fails loudly instead of inheriting whatever the crate next to it was allowed.
  2. A transitive family denylist, checked against the crate’s full dependency tree. Even a dependency the allowlist approves might pull in a chain SDK or an HTTP stack of its own, so the script also walks each crate’s entire resolved dependency tree (via cargo tree, across every feature) looking for package-name prefixes belonging to forbidden families: chain SDKs, HTTP clients, storage drivers, web frameworks. A crate gets an exemption from one specific family only when its own direct allowlist already approved that exact family on purpose (the crate that talks to sqlite is allowed to have sqlite show up in its tree; nothing else is exempted for it).

Layered together, these two checks are what let this wiki describe the workspace’s rings as verified facts rather than a design intention: a core crate cannot quietly grow a chain dependency without a CI failure naming exactly which crate and which package tripped the check, and a module crate cannot quietly become a second way to depend on the engine, because “may this crate depend on blockwatcher-core” is itself one of the questions the allowlist answers.

The same script also has two crate-shaped rules beyond the two general layers: it checks by name that nothing outside [dev-dependencies] depends on either of the two test-scaffolding crates, since an ordinary allowlist miss would only catch that accidentally; and it gives one crate (the one whose entire purpose is driving black-box tests against the real binary) a deliberately empty allowlist, so that crate fails the check the moment it declares any production dependency at all, by construction rather than by someone remembering to keep it empty.